From aa3cc31eac5952bb82e0dc933773e7e56bb7ac18 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 3 May 2026 13:06:05 +0200 Subject: [PATCH 01/31] feat(cli): restructure maestro-discord into verb-based commands Split the agent->Discord CLI into `send`, `notify`, and `status` verbs so the surface can grow beyond the original single-purpose flag form. Verbs use Node's built-in parseArgs and live in src/cli/verbs/, with shared HTTP and exec helpers in src/cli/lib.ts. All three currently ride the existing /api/send endpoint; no server changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli/lib.ts | 78 ++++++++++++++++++++++++ src/cli/maestro-discord.ts | 119 ++++++++++++------------------------- src/cli/verbs/notify.ts | 104 ++++++++++++++++++++++++++++++++ src/cli/verbs/send.ts | 59 ++++++++++++++++++ src/cli/verbs/status.ts | 105 ++++++++++++++++++++++++++++++++ 5 files changed, 383 insertions(+), 82 deletions(-) create mode 100644 src/cli/lib.ts create mode 100644 src/cli/verbs/notify.ts create mode 100644 src/cli/verbs/send.ts create mode 100644 src/cli/verbs/status.ts diff --git a/src/cli/lib.ts b/src/cli/lib.ts new file mode 100644 index 0000000..04adf32 --- /dev/null +++ b/src/cli/lib.ts @@ -0,0 +1,78 @@ +import http from 'http'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); + +export interface SendApiPayload { + agentId: string; + message: string; + mention?: boolean; +} + +export interface SendApiResult { + success: boolean; + channelId?: string; + error?: string; +} + +export const DEFAULT_PORT = 3457; + +export function postToSendApi(payload: SendApiPayload, port: number): Promise { + const body = JSON.stringify(payload); + + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: '127.0.0.1', + port, + path: '/api/send', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + }, + (res) => { + let chunks = ''; + res.on('data', (c) => (chunks += c)); + res.on('end', () => { + try { + resolve(JSON.parse(chunks) as SendApiResult); + } catch { + reject(new Error('Invalid response from bot')); + } + }); + }, + ); + + req.on('error', (err) => { + if ((err as NodeJS.ErrnoException).code === 'ECONNREFUSED') { + reject(new Error('Bot is not running or API server is not started')); + } else { + reject(err); + } + }); + + req.write(body); + req.end(); + }); +} + +export async function runMaestroCli(args: string[], timeoutMs = 10_000): Promise { + const { stdout } = await execFileAsync('maestro-cli', args, { + timeout: timeoutMs, + maxBuffer: 10 * 1024 * 1024, + }); + return stdout.trim(); +} + +export function fail(message: string, code = 1): never { + console.error(`Error: ${message}`); + process.exit(code); +} + +export function ok(result: SendApiResult): never { + console.log(JSON.stringify(result)); + process.exit(result.success ? 0 : 1); +} diff --git a/src/cli/maestro-discord.ts b/src/cli/maestro-discord.ts index 3baeb8c..b250808 100644 --- a/src/cli/maestro-discord.ts +++ b/src/cli/maestro-discord.ts @@ -1,95 +1,50 @@ #!/usr/bin/env node -import http from 'http'; +import { runNotify, notifyUsage } from './verbs/notify'; +import { runSend, sendUsage } from './verbs/send'; +import { runStatus, statusUsage } from './verbs/status'; -function printUsage() { - console.log(`Usage: maestro-discord --agent --message [--mention] [--port ] +const ROOT_USAGE = `Usage: maestro-discord [options] -Options: - --agent Maestro agent ID (required) - --message Message text to send (required) - --mention Mention users in the Discord channel - --port API port (default: 3457) - --help Show this help`); +Verbs: + send Send a message to an agent's Discord channel + notify Post a styled toast/flash notification to an agent's channel + status Post the agent's current status (cwd, usage, tokens) to its channel + +Run 'maestro-discord --help' for verb-specific options.`; + +function printRootHelp(): void { + console.log(ROOT_USAGE); + console.log('\n--- send ---\n' + sendUsage); + console.log('\n--- notify ---\n' + notifyUsage); + console.log('\n--- status ---\n' + statusUsage); } -let agentId = ''; -let message = ''; -let mention = false; -let port = 3457; +async function main(): Promise { + const [verb, ...rest] = process.argv.slice(2); + + if (!verb || verb === '--help' || verb === '-h') { + printRootHelp(); + process.exit(verb ? 0 : 1); + } -const args = process.argv.slice(2); -for (let i = 0; i < args.length; i++) { - switch (args[i]) { - case '--agent': - agentId = args[++i] || ''; - break; - case '--message': - message = args[++i] || ''; - break; - case '--mention': - mention = true; - break; - case '--port': - port = parseInt(args[++i] || '3457', 10); - break; - case '--help': - printUsage(); - process.exit(0); - break; + switch (verb) { + case 'send': + await runSend(rest); + return; + case 'notify': + await runNotify(rest); + return; + case 'status': + await runStatus(rest); + return; default: - console.error(`Unknown flag: ${args[i]}`); + console.error(`Unknown verb: ${verb}\n`); + console.error(ROOT_USAGE); process.exit(1); } } -if (!agentId || !message) { - console.error('Error: --agent and --message are required\n'); - printUsage(); - process.exit(1); -} - -const payload = JSON.stringify({ agentId, message, mention }); - -const req = http.request( - { - hostname: '127.0.0.1', - port, - path: '/api/send', - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(payload), - }, - }, - (res) => { - let body = ''; - res.on('data', (chunk) => (body += chunk)); - res.on('end', () => { - try { - const result = JSON.parse(body); - if (result.success) { - console.log(JSON.stringify(result)); - process.exit(0); - } else { - console.error(JSON.stringify(result)); - process.exit(1); - } - } catch { - console.error('Invalid response from bot'); - process.exit(1); - } - }); - }, -); - -req.on('error', (err) => { - if ((err as NodeJS.ErrnoException).code === 'ECONNREFUSED') { - console.error('Error: Bot is not running or API server is not started'); - } else { - console.error(`Error: ${err.message}`); - } +main().catch((err) => { + console.error(`Error: ${(err as Error).message}`); process.exit(1); }); - -req.write(payload); -req.end(); diff --git a/src/cli/verbs/notify.ts b/src/cli/verbs/notify.ts new file mode 100644 index 0000000..b276c9c --- /dev/null +++ b/src/cli/verbs/notify.ts @@ -0,0 +1,104 @@ +import { parseArgs } from 'node:util'; +import { DEFAULT_PORT, fail, ok, postToSendApi } from '../lib'; + +export const notifyUsage = `Usage: maestro-discord notify [options] + +Post a styled notification message to an agent's Discord channel. Color maps +to a leading emoji so the alert stands out from regular messages. + +Subcommands: + toast --agent --title --message [--color ] + flash --agent --message [--detail ] [--color ] + +Options: + -a, --agent Maestro agent ID (required) + -t, --title Title line (toast only, required) + -m, --message Body text (required) + -D, --detail Second line (flash only, optional) + -c, --color green | yellow | orange | red | theme (default: theme) + --mention Mention the user set in DISCORD_MENTION_USER_ID + -p, --port API port (default: ${DEFAULT_PORT}) + -h, --help Show this help`; + +const COLOR_EMOJI: Record = { + green: '🟒', + yellow: '🟑', + orange: '🟠', + red: 'πŸ”΄', + theme: 'πŸ””', +}; + +function emojiFor(color: string | undefined, fallback: string): string { + if (!color) return fallback; + const e = COLOR_EMOJI[color]; + if (!e) fail(`--color must be one of: ${Object.keys(COLOR_EMOJI).join(', ')}`); + return color === 'theme' ? fallback : e; +} + +export async function runNotify(argv: string[]): Promise { + const [sub, ...rest] = argv; + + if (!sub || sub === '--help' || sub === '-h') { + console.log(notifyUsage); + process.exit(sub ? 0 : 1); + } + + if (sub !== 'toast' && sub !== 'flash') { + fail(`Unknown notify subcommand: ${sub}. Expected 'toast' or 'flash'.`); + } + + let parsed; + try { + parsed = parseArgs({ + args: rest, + options: { + agent: { type: 'string', short: 'a' }, + title: { type: 'string', short: 't' }, + message: { type: 'string', short: 'm' }, + detail: { type: 'string', short: 'D' }, + color: { type: 'string', short: 'c' }, + mention: { type: 'boolean', default: false }, + port: { type: 'string', short: 'p' }, + help: { type: 'boolean', short: 'h', default: false }, + }, + allowPositionals: false, + strict: true, + }); + } catch (err) { + fail((err as Error).message); + } + + if (parsed.values.help) { + console.log(notifyUsage); + process.exit(0); + } + + const agentId = parsed.values.agent; + if (!agentId) fail('--agent is required'); + + const port = parsed.values.port ? parseInt(parsed.values.port, 10) : DEFAULT_PORT; + if (Number.isNaN(port)) fail('--port must be a number'); + + let content: string; + if (sub === 'toast') { + if (!parsed.values.title) fail('toast requires --title'); + if (!parsed.values.message) fail('toast requires --message'); + const icon = emojiFor(parsed.values.color, 'πŸ””'); + content = `${icon} **${parsed.values.title}**\n${parsed.values.message}`; + } else { + if (!parsed.values.message) fail('flash requires --message'); + const icon = emojiFor(parsed.values.color, '⚑'); + content = `${icon} ${parsed.values.message}`; + if (parsed.values.detail) content += `\n${parsed.values.detail}`; + } + + try { + const result = await postToSendApi( + { agentId, message: content, mention: parsed.values.mention }, + port, + ); + ok(result); + } catch (err) { + fail((err as Error).message); + } +} diff --git a/src/cli/verbs/send.ts b/src/cli/verbs/send.ts new file mode 100644 index 0000000..d981f49 --- /dev/null +++ b/src/cli/verbs/send.ts @@ -0,0 +1,59 @@ +import { parseArgs } from 'node:util'; +import { DEFAULT_PORT, fail, ok, postToSendApi } from '../lib'; + +export const sendUsage = `Usage: maestro-discord send --agent --message [--mention] [--port ] + +Send a message to an agent's Discord channel (auto-creates channel if needed). + +Options: + -a, --agent Maestro agent ID (required) + -m, --message Message text to send (required) + --mention Mention the user set in DISCORD_MENTION_USER_ID + -p, --port API port (default: ${DEFAULT_PORT}) + -h, --help Show this help`; + +export async function runSend(argv: string[]): Promise { + let parsed; + try { + parsed = parseArgs({ + args: argv, + options: { + agent: { type: 'string', short: 'a' }, + message: { type: 'string', short: 'm' }, + mention: { type: 'boolean', default: false }, + port: { type: 'string', short: 'p' }, + help: { type: 'boolean', short: 'h', default: false }, + }, + allowPositionals: false, + strict: true, + }); + } catch (err) { + fail((err as Error).message); + } + + if (parsed.values.help) { + console.log(sendUsage); + process.exit(0); + } + + const agentId = parsed.values.agent; + const message = parsed.values.message; + + if (!agentId || !message) { + console.error(sendUsage); + fail('--agent and --message are required'); + } + + const port = parsed.values.port ? parseInt(parsed.values.port, 10) : DEFAULT_PORT; + if (Number.isNaN(port)) fail('--port must be a number'); + + try { + const result = await postToSendApi( + { agentId, message, mention: parsed.values.mention }, + port, + ); + ok(result); + } catch (err) { + fail((err as Error).message); + } +} diff --git a/src/cli/verbs/status.ts b/src/cli/verbs/status.ts new file mode 100644 index 0000000..bd9b284 --- /dev/null +++ b/src/cli/verbs/status.ts @@ -0,0 +1,105 @@ +import { parseArgs } from 'node:util'; +import { DEFAULT_PORT, fail, ok, postToSendApi, runMaestroCli } from '../lib'; + +export const statusUsage = `Usage: maestro-discord status --agent [--port ] + +Fetch agent details from maestro-cli and post a formatted status summary to +the agent's Discord channel. + +Options: + -a, --agent Maestro agent ID (required) + --mention Mention the user set in DISCORD_MENTION_USER_ID + -p, --port API port (default: ${DEFAULT_PORT}) + -h, --help Show this help`; + +interface AgentDetail { + id?: string; + name?: string; + toolType?: string; + cwd?: string; + status?: string; + usage?: { + inputTokens?: number; + outputTokens?: number; + totalCostUsd?: number; + contextUsagePercent?: number; + }; + [key: string]: unknown; +} + +function formatStatus(detail: AgentDetail): string { + const lines: string[] = []; + const name = detail.name ?? detail.id ?? 'unknown'; + lines.push(`πŸ“Š **Status: ${name}**`); + if (detail.toolType) lines.push(`Tool: \`${detail.toolType}\``); + if (detail.cwd) lines.push(`Cwd: \`${detail.cwd}\``); + if (detail.status) lines.push(`State: ${detail.status}`); + + const u = detail.usage; + if (u) { + const parts: string[] = []; + if (typeof u.contextUsagePercent === 'number') { + parts.push(`context ${u.contextUsagePercent.toFixed(1)}%`); + } + if (typeof u.totalCostUsd === 'number') { + parts.push(`$${u.totalCostUsd.toFixed(4)}`); + } + if (typeof u.inputTokens === 'number' && typeof u.outputTokens === 'number') { + parts.push(`${u.inputTokens}↓ ${u.outputTokens}↑ tokens`); + } + if (parts.length) lines.push(`Usage: ${parts.join(' Β· ')}`); + } + + return lines.join('\n'); +} + +export async function runStatus(argv: string[]): Promise { + let parsed; + try { + parsed = parseArgs({ + args: argv, + options: { + agent: { type: 'string', short: 'a' }, + mention: { type: 'boolean', default: false }, + port: { type: 'string', short: 'p' }, + help: { type: 'boolean', short: 'h', default: false }, + }, + allowPositionals: false, + strict: true, + }); + } catch (err) { + fail((err as Error).message); + } + + if (parsed.values.help) { + console.log(statusUsage); + process.exit(0); + } + + const agentId = parsed.values.agent; + if (!agentId) { + console.error(statusUsage); + fail('--agent is required'); + } + + const port = parsed.values.port ? parseInt(parsed.values.port, 10) : DEFAULT_PORT; + if (Number.isNaN(port)) fail('--port must be a number'); + + let detail: AgentDetail; + try { + const raw = await runMaestroCli(['show', 'agent', agentId, '--json']); + detail = JSON.parse(raw) as AgentDetail; + } catch (err) { + fail(`maestro-cli show agent failed: ${(err as Error).message}`); + } + + try { + const result = await postToSendApi( + { agentId, message: formatStatus(detail), mention: parsed.values.mention }, + port, + ); + ok(result); + } catch (err) { + fail((err as Error).message); + } +} From b54737721be8057f77c7aac5c2d7ff01aaeaf231 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 3 May 2026 13:06:17 +0200 Subject: [PATCH 02/31] feat(commands): add /playbook, /gist, /notes, /auto-run, /agents show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface previously-unused maestro-cli capabilities as Discord slash commands: - /playbook list|show|run β€” list playbooks, show details, run with completion summary posted to the channel (service layer was already wired) - /agents show β€” embed of agent stats and recent activity - /gist [description] [public] β€” publish the channel's agent transcript as a GitHub gist; returns the URL in-channel - /notes synopsis|history β€” Director's Notes (AI synopsis with 2 min LLM timeout, or unified history feed across agents) - /auto-run start β€” launch an Auto Run for the channel's agent; bare filenames are resolved against the agent's autoRunFolderPath and autocomplete reads .md files from that directory Adds five service methods (showAgent, createGist, directorSynopsis, directorHistory, startAutoRun) and supporting types. Introduces a CommandModule type in index.ts so the command registry Map type-unifies across builders with different shapes (subcommand-only vs options-only). Updates the README slash-command table. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 8 ++ src/commands/agents.ts | 80 ++++++++++++++ src/commands/auto-run.ts | 136 +++++++++++++++++++++++ src/commands/gist.ts | 55 ++++++++++ src/commands/notes.ts | 156 +++++++++++++++++++++++++++ src/commands/playbook.ts | 226 +++++++++++++++++++++++++++++++++++++++ src/deploy-commands.ts | 14 ++- src/index.ts | 29 ++++- src/services/maestro.ts | 111 +++++++++++++++++++ 9 files changed, 809 insertions(+), 6 deletions(-) create mode 100644 src/commands/auto-run.ts create mode 100644 src/commands/gist.ts create mode 100644 src/commands/notes.ts create mode 100644 src/commands/playbook.ts diff --git a/README.md b/README.md index a0fa81f..4f9a72c 100644 --- a/README.md +++ b/README.md @@ -158,10 +158,18 @@ npm run build && node --test --experimental-test-coverage dist/__tests__/**/*.te | `/health` | Verify Maestro CLI is installed and working | | `/agents list` | Show all available agents | | `/agents new ` | Create a dedicated channel for an agent (autocomplete) | +| `/agents show ` | Show an agent's stats and recent activity | | `/agents disconnect` | (Run inside an agent channel) Remove and delete the channel | | `/agents readonly on\|off` | Toggle read-only mode for the current agent channel | | `/session new` | Create a new owner-bound thread for the current agent channel | | `/session list` | List session threads for the current agent channel | +| `/playbook list` | List playbooks (optionally filter by agent) | +| `/playbook show ` | Show details for a playbook | +| `/playbook run ` | Run a playbook and post the completion summary in-channel | +| `/auto-run start ` | Launch an Auto Run document for the current agent channel | +| `/gist` | Publish the current agent's session transcript as a GitHub gist | +| `/notes synopsis` | Post an AI-generated synopsis of recent activity | +| `/notes history` | Post a unified history feed across agents | ## How it works diff --git a/src/commands/agents.ts b/src/commands/agents.ts index 4f511a7..145abb5 100644 --- a/src/commands/agents.ts +++ b/src/commands/agents.ts @@ -35,6 +35,18 @@ export const data = new SlashCommandBuilder() .setAutocomplete(true), ), ) + .addSubcommand((sub) => + sub + .setName('show') + .setDescription("Show an agent's details, stats, and recent activity") + .addStringOption((opt) => + opt + .setName('agent') + .setDescription('Select an agent') + .setRequired(true) + .setAutocomplete(true), + ), + ) .addSubcommand((sub) => sub.setName('disconnect').setDescription('Remove this agent channel (deletes the channel)'), ) @@ -86,6 +98,8 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise await handleList(interaction); } else if (sub === 'new') { await handleNew(interaction); + } else if (sub === 'show') { + await handleShow(interaction); } else if (sub === 'disconnect') { await handleDisconnect(interaction); } else if (sub === 'readonly') { @@ -194,6 +208,72 @@ async function handleNew(interaction: ChatInputCommandInteraction): Promise { + await interaction.deferReply({ ephemeral: true }); + + const agentId = interaction.options.getString('agent', true); + + let detail; + try { + detail = await maestro.showAgent(agentId); + } catch (err) { + await interaction.editReply(`❌ Could not load agent: ${(err as Error).message}`); + return; + } + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setTitle(detail.name) + .addFields( + { name: 'ID', value: `\`${detail.id}\``, inline: false }, + { name: 'Tool', value: detail.toolType, inline: true }, + { name: 'Cwd', value: `\`${detail.cwd}\``, inline: false }, + ); + + if (detail.groupName) { + embed.addFields({ name: 'Group', value: detail.groupName, inline: true }); + } + + const stats = detail.stats; + if (stats) { + const statLines: string[] = []; + if (typeof stats.historyEntries === 'number') { + const ok = stats.successCount ?? 0; + const fail = stats.failureCount ?? 0; + statLines.push(`History: ${stats.historyEntries} entries (${ok} ok Β· ${fail} failed)`); + } + if (typeof stats.totalInputTokens === 'number' || typeof stats.totalOutputTokens === 'number') { + statLines.push( + `Tokens: ${stats.totalInputTokens ?? 0}↓ ${stats.totalOutputTokens ?? 0}↑`, + ); + } + if (typeof stats.totalCost === 'number' && stats.totalCost > 0) { + statLines.push(`Cost: $${stats.totalCost.toFixed(4)}`); + } + if (typeof stats.totalElapsedMs === 'number' && stats.totalElapsedMs > 0) { + statLines.push(`Total elapsed: ${(stats.totalElapsedMs / 1000).toFixed(1)}s`); + } + if (statLines.length) { + embed.addFields({ name: 'Stats', value: statLines.join('\n') }); + } + } + + if (detail.recentHistory && detail.recentHistory.length > 0) { + const recent = detail.recentHistory + .slice(0, 5) + .map((h) => { + const when = new Date(h.timestamp).toLocaleString(); + const status = h.success === false ? '⚠️' : 'β€’'; + const summary = (h.summary ?? '').slice(0, 90); + return `${status} ${when} β€” ${summary}`; + }) + .join('\n'); + embed.addFields({ name: 'Recent activity', value: recent }); + } + + await interaction.editReply({ embeds: [embed] }); +} + async function handleReadonly(interaction: ChatInputCommandInteraction): Promise { const channelInfo = channelDb.get(interaction.channelId); if (!channelInfo) { diff --git a/src/commands/auto-run.ts b/src/commands/auto-run.ts new file mode 100644 index 0000000..1e3b8c1 --- /dev/null +++ b/src/commands/auto-run.ts @@ -0,0 +1,136 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +import { + AutocompleteInteraction, + ChatInputCommandInteraction, + SlashCommandBuilder, +} from 'discord.js'; +import { channelDb } from '../db'; +import { maestro } from '../services/maestro'; + +export const data = new SlashCommandBuilder() + .setName('auto-run') + .setDescription("Launch one of this agent's Auto Run documents") + .addSubcommand((sub) => + sub + .setName('start') + .setDescription("Configure and launch an Auto Run for this channel's agent") + .addStringOption((opt) => + opt + .setName('doc') + .setDescription('Auto Run document (filename or path)') + .setRequired(true) + .setAutocomplete(true), + ) + .addStringOption((opt) => + opt.setName('prompt').setDescription('Override the default prompt').setRequired(false), + ) + .addIntegerOption((opt) => + opt + .setName('max_loops') + .setDescription('Loop the run up to N times') + .setMinValue(1) + .setMaxValue(50) + .setRequired(false), + ) + .addBooleanOption((opt) => + opt + .setName('reset_on_completion') + .setDescription('Reset all task checkboxes when the run finishes') + .setRequired(false), + ), + ); + +async function getAgentFolder(agentId: string): Promise { + try { + const agents = await maestro.listAgents(); + const agent = agents.find((a) => a.id === agentId); + const folder = agent && (agent as { autoRunFolderPath?: unknown }).autoRunFolderPath; + return typeof folder === 'string' ? folder : null; + } catch { + return null; + } +} + +export async function autocomplete(interaction: AutocompleteInteraction): Promise { + const focused = interaction.options.getFocused(true); + if (focused.name !== 'doc') return interaction.respond([]); + + const channelInfo = channelDb.get(interaction.channelId); + if (!channelInfo) return interaction.respond([]); + + const folder = await getAgentFolder(channelInfo.agent_id); + if (!folder) return interaction.respond([]); + + let entries: string[]; + try { + const dirents = await fs.readdir(folder, { withFileTypes: true }); + entries = dirents + .filter((d) => d.isFile() && d.name.toLowerCase().endsWith('.md')) + .map((d) => d.name); + } catch { + return interaction.respond([]); + } + + const value = focused.value.toLowerCase(); + await interaction.respond( + entries + .filter((n) => n.toLowerCase().includes(value)) + .slice(0, 25) + .map((n) => ({ name: n.slice(0, 100), value: n })), + ); +} + +export async function execute(interaction: ChatInputCommandInteraction): Promise { + const sub = interaction.options.getSubcommand(); + if (sub !== 'start') return; + + const channelInfo = channelDb.get(interaction.channelId); + if (!channelInfo) { + await interaction.reply({ + content: '❌ This channel is not connected to an agent. Use `/agents new` first.', + ephemeral: true, + }); + return; + } + + await interaction.deferReply(); + + const doc = interaction.options.getString('doc', true); + const prompt = interaction.options.getString('prompt') ?? undefined; + const maxLoops = interaction.options.getInteger('max_loops') ?? undefined; + const resetOnCompletion = + interaction.options.getBoolean('reset_on_completion') ?? undefined; + + // If the user typed a bare filename, resolve it against the agent's Auto Run folder. + let docPath = doc; + if (!path.isAbsolute(doc) && !doc.includes('/')) { + const folder = await getAgentFolder(channelInfo.agent_id); + if (folder) docPath = path.join(folder, doc); + } + + try { + await maestro.startAutoRun({ + agentId: channelInfo.agent_id, + docs: [docPath], + prompt, + maxLoops, + resetOnCompletion, + }); + } catch (err) { + await interaction.editReply( + `❌ Auto Run failed to launch: ${(err as Error).message.slice(0, 1500)}`, + ); + return; + } + + const lines: string[] = [ + `▢️ Launched Auto Run for **${channelInfo.agent_name}** with \`${path.basename(docPath)}\`.`, + ]; + if (maxLoops != null) lines.push(`Looping up to ${maxLoops} times.`); + if (prompt) lines.push('Custom prompt set.'); + if (resetOnCompletion) lines.push('Tasks will reset on completion.'); + lines.push('Watch the agent channel for progress.'); + + await interaction.editReply(lines.join('\n')); +} diff --git a/src/commands/gist.ts b/src/commands/gist.ts new file mode 100644 index 0000000..e8af9df --- /dev/null +++ b/src/commands/gist.ts @@ -0,0 +1,55 @@ +import { + ChatInputCommandInteraction, + EmbedBuilder, + SlashCommandBuilder, +} from 'discord.js'; +import { channelDb } from '../db'; +import { maestro } from '../services/maestro'; + +export const data = new SlashCommandBuilder() + .setName('gist') + .setDescription("Publish this agent's session transcript as a GitHub gist") + .addStringOption((opt) => + opt.setName('description').setDescription('Optional gist description').setRequired(false), + ) + .addBooleanOption((opt) => + opt + .setName('public') + .setDescription('Make the gist public (default: private)') + .setRequired(false), + ); + +export async function execute(interaction: ChatInputCommandInteraction): Promise { + const channelInfo = channelDb.get(interaction.channelId); + if (!channelInfo) { + await interaction.reply({ + content: '❌ This channel is not connected to an agent. Use `/agents new` first.', + ephemeral: true, + }); + return; + } + + await interaction.deferReply(); + + const description = interaction.options.getString('description') ?? undefined; + const isPublic = interaction.options.getBoolean('public') ?? false; + + let result; + try { + result = await maestro.createGist(channelInfo.agent_id, { description, isPublic }); + } catch (err) { + await interaction.editReply( + `❌ Could not publish gist: ${(err as Error).message.slice(0, 1500)}`, + ); + return; + } + + const visibility = isPublic ? 'public' : 'private'; + const embed = new EmbedBuilder() + .setColor(0x57f287) + .setTitle(`πŸ“Ž Gist published β€” ${channelInfo.agent_name}`) + .setURL(result.url) + .setDescription(`[Open gist](${result.url})\nVisibility: **${visibility}**`); + + await interaction.editReply({ embeds: [embed] }); +} diff --git a/src/commands/notes.ts b/src/commands/notes.ts new file mode 100644 index 0000000..70426a8 --- /dev/null +++ b/src/commands/notes.ts @@ -0,0 +1,156 @@ +import { + ChatInputCommandInteraction, + EmbedBuilder, + SlashCommandBuilder, +} from 'discord.js'; +import { maestro } from '../services/maestro'; + +export const data = new SlashCommandBuilder() + .setName('notes') + .setDescription("Director's Notes: AI synopsis or unified history across agents") + .addSubcommand((sub) => + sub + .setName('synopsis') + .setDescription('AI-generated synopsis of recent activity (slow β€” runs the LLM)') + .addIntegerOption((opt) => + opt + .setName('days') + .setDescription('Lookback period in days') + .setMinValue(1) + .setMaxValue(30) + .setRequired(false), + ), + ) + .addSubcommand((sub) => + sub + .setName('history') + .setDescription('Recent unified history entries') + .addIntegerOption((opt) => + opt + .setName('days') + .setDescription('Lookback period in days') + .setMinValue(1) + .setMaxValue(30) + .setRequired(false), + ) + .addIntegerOption((opt) => + opt + .setName('limit') + .setDescription('Max entries to show (default 20)') + .setMinValue(1) + .setMaxValue(50) + .setRequired(false), + ) + .addStringOption((opt) => + opt + .setName('filter') + .setDescription('Entry type filter') + .setRequired(false) + .addChoices( + { name: 'auto', value: 'auto' }, + { name: 'user', value: 'user' }, + { name: 'cue', value: 'cue' }, + ), + ), + ); + +export async function execute(interaction: ChatInputCommandInteraction): Promise { + const sub = interaction.options.getSubcommand(); + if (sub === 'synopsis') return handleSynopsis(interaction); + if (sub === 'history') return handleHistory(interaction); +} + +async function handleSynopsis(interaction: ChatInputCommandInteraction): Promise { + await interaction.deferReply(); + + const days = interaction.options.getInteger('days') ?? undefined; + + let result; + try { + result = await maestro.directorSynopsis({ days }); + } catch (err) { + await interaction.editReply( + `❌ Synopsis failed: ${(err as Error).message.slice(0, 1500)}`, + ); + return; + } + + const text = result.markdown ?? result.synopsis ?? result.text ?? '_(empty synopsis)_'; + const truncated = text.length > 4000 ? text.slice(0, 4000) + '\n\n_…truncated_' : text; + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setTitle(`🎬 Director's synopsis${days ? ` β€” last ${days}d` : ''}`) + .setDescription(truncated); + + if (typeof result.entriesAnalyzed === 'number') { + embed.setFooter({ + text: `Analyzed ${result.entriesAnalyzed} entries${ + typeof result.daysCovered === 'number' ? ` over ${result.daysCovered}d` : '' + }`, + }); + } + + await interaction.editReply({ embeds: [embed] }); +} + +async function handleHistory(interaction: ChatInputCommandInteraction): Promise { + await interaction.deferReply({ ephemeral: true }); + + const days = interaction.options.getInteger('days') ?? undefined; + const limit = interaction.options.getInteger('limit') ?? 20; + const filter = interaction.options.getString('filter') as + | 'auto' + | 'user' + | 'cue' + | null; + + let entries; + try { + entries = await maestro.directorHistory({ + days, + limit, + filter: filter ?? undefined, + }); + } catch (err) { + await interaction.editReply( + `❌ History fetch failed: ${(err as Error).message.slice(0, 1500)}`, + ); + return; + } + + if (entries.length === 0) { + await interaction.editReply('No history entries in the requested window.'); + return; + } + + const lines = entries.map((e) => { + const when = e.timestamp ? new Date(e.timestamp).toLocaleString() : 'β€”'; + const type = e.type ?? '?'; + const agent = e.agentName ? ` Β· ${e.agentName}` : ''; + const status = e.success === false ? '⚠️' : 'β€’'; + const summary = (e.summary ?? '').slice(0, 100); + return `${status} \`${type}\` ${when}${agent}\n${summary}`; + }); + + const MAX_DESC = 4096; + let description = ''; + let shown = 0; + for (const line of lines) { + const addition = description ? '\n\n' + line : line; + if (description.length + addition.length > MAX_DESC) break; + description += addition; + shown++; + } + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setTitle(`πŸ“œ Director history${days ? ` β€” last ${days}d` : ''}`) + .setDescription(description); + + if (shown < entries.length) { + embed.setFooter({ text: `Showing ${shown} of ${entries.length}` }); + } + + await interaction.editReply({ embeds: [embed] }); +} diff --git a/src/commands/playbook.ts b/src/commands/playbook.ts new file mode 100644 index 0000000..6d08cb6 --- /dev/null +++ b/src/commands/playbook.ts @@ -0,0 +1,226 @@ +import { + AutocompleteInteraction, + ChatInputCommandInteraction, + EmbedBuilder, + SlashCommandBuilder, +} from 'discord.js'; +import { maestro } from '../services/maestro'; + +export const data = new SlashCommandBuilder() + .setName('playbook') + .setDescription('Run and inspect Maestro playbooks') + .addSubcommand((sub) => + sub + .setName('list') + .setDescription('List available playbooks') + .addStringOption((opt) => + opt + .setName('agent') + .setDescription('Filter to one agent') + .setRequired(false) + .setAutocomplete(true), + ), + ) + .addSubcommand((sub) => + sub + .setName('show') + .setDescription('Show details for a playbook') + .addStringOption((opt) => + opt + .setName('playbook') + .setDescription('Playbook to show') + .setRequired(true) + .setAutocomplete(true), + ), + ) + .addSubcommand((sub) => + sub + .setName('run') + .setDescription('Run a playbook and post the result here') + .addStringOption((opt) => + opt + .setName('playbook') + .setDescription('Playbook to run') + .setRequired(true) + .setAutocomplete(true), + ), + ); + +export async function autocomplete(interaction: AutocompleteInteraction): Promise { + const focused = interaction.options.getFocused(true); + const value = focused.value.toLowerCase(); + + try { + if (focused.name === 'agent') { + const agents = await maestro.listAgents(); + await interaction.respond( + agents + .filter( + (a) => a.name.toLowerCase().includes(value) || a.id.toLowerCase().includes(value), + ) + .slice(0, 25) + .map((a) => ({ name: `${a.name} (${a.toolType})`, value: a.id })), + ); + return; + } + + if (focused.name === 'playbook') { + const playbooks = await maestro.listPlaybooks(); + await interaction.respond( + playbooks + .filter( + (p) => p.name.toLowerCase().includes(value) || p.id.toLowerCase().includes(value), + ) + .slice(0, 25) + .map((p) => ({ + name: `${p.name}${p.agentName ? ` (${p.agentName})` : ''}`.slice(0, 100), + value: p.id, + })), + ); + return; + } + } catch { + await interaction.respond([]); + } +} + +export async function execute(interaction: ChatInputCommandInteraction): Promise { + const sub = interaction.options.getSubcommand(); + if (sub === 'list') return handleList(interaction); + if (sub === 'show') return handleShow(interaction); + if (sub === 'run') return handleRun(interaction); +} + +async function handleList(interaction: ChatInputCommandInteraction): Promise { + await interaction.deferReply({ ephemeral: true }); + + const agentId = interaction.options.getString('agent') ?? undefined; + const playbooks = await maestro.listPlaybooks(agentId); + + if (playbooks.length === 0) { + await interaction.editReply( + agentId + ? 'No playbooks found for that agent.' + : 'No playbooks found. Create one in the Maestro app first.', + ); + return; + } + + const lines = playbooks.map((p) => { + const owner = p.agentName ? ` Β· ${p.agentName}` : ''; + return `**${p.name}**${owner}\n\`${p.id}\` Β· ${p.documentCount} docs Β· ${p.taskCount} tasks`; + }); + + const MAX_DESC = 4096; + let description = ''; + let shown = 0; + for (const line of lines) { + const addition = description ? '\n\n' + line : line; + if (description.length + addition.length > MAX_DESC) break; + description += addition; + shown++; + } + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setTitle('Playbooks') + .setDescription(description); + + if (shown < playbooks.length) { + embed.setFooter({ text: `Showing ${shown} of ${playbooks.length}` }); + } + + await interaction.editReply({ embeds: [embed] }); +} + +async function handleShow(interaction: ChatInputCommandInteraction): Promise { + await interaction.deferReply({ ephemeral: true }); + + const playbookId = interaction.options.getString('playbook', true); + + let detail; + try { + detail = await maestro.showPlaybook(playbookId); + } catch (err) { + await interaction.editReply(`❌ Could not load playbook: ${(err as Error).message}`); + return; + } + + const docLines = detail.documents + .slice(0, 15) + .map((d) => `β€’ \`${d.path}\` β€” ${d.completedCount}/${d.taskCount} tasks`); + if (detail.documents.length > 15) { + docLines.push(`… and ${detail.documents.length - 15} more`); + } + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setTitle(detail.name) + .setDescription(detail.description || '_(no description)_') + .addFields( + { name: 'ID', value: `\`${detail.id}\``, inline: true }, + { + name: 'Tasks', + value: `${detail.taskCount} (${detail.documentCount} docs)`, + inline: true, + }, + ); + + if (detail.agentName) { + embed.addFields({ name: 'Agent', value: detail.agentName, inline: true }); + } + if (docLines.length) { + embed.addFields({ name: 'Documents', value: docLines.join('\n') }); + } + + await interaction.editReply({ embeds: [embed] }); +} + +async function handleRun(interaction: ChatInputCommandInteraction): Promise { + // Public reply β€” playbook runs are interesting to the channel + await interaction.deferReply(); + + const playbookId = interaction.options.getString('playbook', true); + + let detail; + try { + detail = await maestro.showPlaybook(playbookId); + } catch { + detail = null; + } + const label = detail?.name ?? playbookId; + + await interaction.editReply(`▢️ Running playbook **${label}**…`); + + let event; + try { + event = await maestro.runPlaybook(playbookId); + } catch (err) { + await interaction.editReply( + `❌ Playbook **${label}** failed: ${(err as Error).message.slice(0, 1500)}`, + ); + return; + } + + const lines: string[] = [ + event.success === false + ? `⚠️ Playbook **${label}** finished with errors.` + : `βœ… Playbook **${label}** complete.`, + ]; + if (typeof event.totalTasksCompleted === 'number') { + lines.push(`Tasks completed: **${event.totalTasksCompleted}**`); + } + if (typeof event.totalElapsedMs === 'number') { + const seconds = (event.totalElapsedMs / 1000).toFixed(1); + lines.push(`Elapsed: ${seconds}s`); + } + if (typeof event.totalCost === 'number' && event.totalCost > 0) { + lines.push(`Cost: $${event.totalCost.toFixed(4)}`); + } + if (event.summary) { + const summary = String(event.summary).slice(0, 1500); + lines.push('', summary); + } + + await interaction.editReply(lines.join('\n')); +} diff --git a/src/deploy-commands.ts b/src/deploy-commands.ts index 06a1fe7..3ef5441 100644 --- a/src/deploy-commands.ts +++ b/src/deploy-commands.ts @@ -3,8 +3,20 @@ import { config } from './config'; import * as health from './commands/health'; import * as agents from './commands/agents'; import * as session from './commands/session'; +import * as playbook from './commands/playbook'; +import * as gist from './commands/gist'; +import * as notes from './commands/notes'; +import * as autoRun from './commands/auto-run'; -const commands = [health.data.toJSON(), agents.data.toJSON(), session.data.toJSON()]; +const commands = [ + health.data.toJSON(), + agents.data.toJSON(), + session.data.toJSON(), + playbook.data.toJSON(), + gist.data.toJSON(), + notes.data.toJSON(), + autoRun.data.toJSON(), +]; const rest = new REST().setToken(config.token); diff --git a/src/index.ts b/src/index.ts index 2596c7d..88c6437 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,17 +1,38 @@ -import { Client, GatewayIntentBits, Interaction } from 'discord.js'; +import { + AutocompleteInteraction, + ChatInputCommandInteraction, + Client, + GatewayIntentBits, + Interaction, + SlashCommandBuilder, +} from 'discord.js'; import { config } from './config'; import * as health from './commands/health'; import * as agents from './commands/agents'; import * as session from './commands/session'; +import * as playbook from './commands/playbook'; +import * as gist from './commands/gist'; +import * as notes from './commands/notes'; +import * as autoRun from './commands/auto-run'; import './db'; // ensure DB is initialized on startup + +interface CommandModule { + data: { name: string } & Pick; + execute(interaction: ChatInputCommandInteraction): Promise; + autocomplete?(interaction: AutocompleteInteraction): Promise; +} import { checkTranscriptionDependencies } from './services/transcription'; import { handleMessageCreate } from './handlers/messageCreate'; import { startServer } from './server'; -const commands = new Map([ +const commands = new Map([ [health.data.name, health], [agents.data.name, agents], [session.data.name, session], + [playbook.data.name, playbook], + [gist.data.name, gist], + [notes.data.name, notes], + [autoRun.data.name, autoRun], ]); const client = new Client({ @@ -39,9 +60,7 @@ client.on('interactionCreate', async (interaction: Interaction) => { await interaction.respond([]); return; } - const cmd = commands.get(interaction.commandName) as { - autocomplete?: (i: typeof interaction) => Promise; - }; + const cmd = commands.get(interaction.commandName); if (cmd?.autocomplete) { try { await cmd.autocomplete(interaction); diff --git a/src/services/maestro.ts b/src/services/maestro.ts index 6be02a5..f584d5c 100644 --- a/src/services/maestro.ts +++ b/src/services/maestro.ts @@ -55,6 +55,63 @@ export interface MaestroPlaybook { [key: string]: unknown; } +export interface MaestroAgentDetail extends MaestroAgent { + projectRoot?: string; + groupName?: string; + autoRunFolderPath?: string; + stats?: { + historyEntries?: number; + successCount?: number; + failureCount?: number; + totalInputTokens?: number; + totalOutputTokens?: number; + totalCost?: number; + totalElapsedMs?: number; + }; + recentHistory?: Array<{ + id: string; + type: string; + timestamp: number; + summary: string; + success?: boolean; + elapsedTimeMs?: number; + }>; +} + +export interface GistResult { + url: string; + id: string; + [key: string]: unknown; +} + +export interface DirectorNotesEntry { + id?: string; + type?: string; + timestamp?: number; + summary?: string; + agentName?: string; + success?: boolean; + [key: string]: unknown; +} + +export interface DirectorSynopsis { + synopsis?: string; + text?: string; + markdown?: string; + daysCovered?: number; + entriesAnalyzed?: number; + [key: string]: unknown; +} + +export interface AutoRunOptions { + agentId: string; + docs: string[]; + prompt?: string; + loop?: boolean; + maxLoops?: number; + resetOnCompletion?: boolean; +} + export interface MaestroPlaybookDetail extends MaestroPlaybook { documents: Array<{ path: string; @@ -254,6 +311,60 @@ export const maestro = { return JSON.parse(raw) as MaestroPlaybookDetail; }, + /** Show detailed agent info including stats and recent history */ + async showAgent(agentId: string): Promise { + const raw = await run(['show', 'agent', agentId, '--json']); + return JSON.parse(raw) as MaestroAgentDetail; + }, + + /** Publish an agent's session transcript as a GitHub gist */ + async createGist( + agentId: string, + opts: { description?: string; isPublic?: boolean } = {}, + ): Promise { + const args = ['gist', 'create', agentId]; + if (opts.description) args.push('-d', opts.description); + if (opts.isPublic) args.push('-p'); + const raw = await run(args, { timeoutMs: 60_000 }); + return JSON.parse(raw) as GistResult; + }, + + /** Generate AI synopsis of recent activity (requires running Maestro app) */ + async directorSynopsis(opts: { days?: number } = {}): Promise { + const args = ['director-notes', 'synopsis', '--json']; + if (opts.days != null) args.push('-d', String(opts.days)); + // Synopsis generation involves AI inference β€” give it 2 minutes + const raw = await run(args, { timeoutMs: 120_000 }); + return JSON.parse(raw) as DirectorSynopsis; + }, + + /** Show unified history across all agents */ + async directorHistory( + opts: { days?: number; limit?: number; filter?: 'auto' | 'user' | 'cue' } = {}, + ): Promise { + const args = ['director-notes', 'history', '--json']; + if (opts.days != null) args.push('-d', String(opts.days)); + if (opts.limit != null) args.push('-l', String(opts.limit)); + if (opts.filter) args.push('--filter', opts.filter); + const raw = await run(args); + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) return parsed as DirectorNotesEntry[]; + if (Array.isArray(parsed?.entries)) return parsed.entries as DirectorNotesEntry[]; + return []; + }, + + /** Configure and launch an Auto Run with the given documents */ + async startAutoRun(opts: AutoRunOptions): Promise { + if (!opts.docs.length) throw new Error('startAutoRun requires at least one document'); + const args = ['auto-run', '--launch', '--agent', opts.agentId]; + if (opts.prompt) args.push('--prompt', opts.prompt); + if (opts.maxLoops != null) args.push('--max-loops', String(opts.maxLoops)); + else if (opts.loop) args.push('--loop'); + if (opts.resetOnCompletion) args.push('--reset-on-completion'); + args.push(...opts.docs); + return run(args, { timeoutMs: 60_000 }); + }, + /** Run a playbook and return the final completion event. Uses --wait so the CLI blocks until done. */ async runPlaybook(playbookId: string): Promise { const raw = await run(['playbook', playbookId, '--wait'], { From 7fe21965ba2da62cf140d9e4999f7f5bf8e8c90b Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 3 May 2026 15:25:54 +0200 Subject: [PATCH 03/31] fix(commands): clamp embed text to Discord limits in /playbook + /agents show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord rejects embed payloads with description > 4096 chars or field value > 1024 chars. Long playbook descriptions, long document path lists, long cwds, and accumulated recent-activity text could all trip these limits and fail editReply with a validation error. Adds src/utils/embed.ts with clampDescription and clampFieldValue helpers (append a "\n…" marker when truncating) and applies them to: - /playbook show β€” description and Documents field - /agents show β€” Cwd, Stats, and Recent activity fields Also adds focused tests: - embed.test.ts β€” clamp limits and edge cases - playbook-command.test.ts β€” list/show happy paths, oversize-input clamp, load-failure error path - agents-command.test.ts β€” show happy path, oversize-cwd clamp, load-failure error path Test count: 110 -> 122 (all green). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/__tests__/agents-command.test.ts | 93 +++++++++++++++++++++ src/__tests__/embed.test.ts | 36 ++++++++ src/__tests__/playbook-command.test.ts | 109 +++++++++++++++++++++++++ src/commands/agents.ts | 7 +- src/commands/playbook.ts | 5 +- src/utils/embed.ts | 14 ++++ 6 files changed, 259 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/embed.test.ts create mode 100644 src/__tests__/playbook-command.test.ts create mode 100644 src/utils/embed.ts diff --git a/src/__tests__/agents-command.test.ts b/src/__tests__/agents-command.test.ts index a7999c8..8fec926 100644 --- a/src/__tests__/agents-command.test.ts +++ b/src/__tests__/agents-command.test.ts @@ -1,6 +1,7 @@ import test, { afterEach, mock } from 'node:test'; import assert from 'node:assert/strict'; import { execute, autocomplete } from '../commands/agents'; +import { EMBED_FIELD_VALUE_MAX } from '../utils/embed'; afterEach(() => { mock.restoreAll(); @@ -169,6 +170,98 @@ test('agents new matches agent by prefix', async () => { assert.ok(reply.includes('PrefixBot')); }); +// --- /agents show --- + +test('agents show renders an embed with stats and recent activity', async () => { + const { maestro } = await import('../services/maestro'); + mock.method(maestro, 'showAgent', async () => ({ + id: 'agent-1', + name: 'TestBot', + toolType: 'claude', + cwd: '/proj', + groupName: 'Group A', + stats: { + historyEntries: 12, + successCount: 10, + failureCount: 2, + totalInputTokens: 5000, + totalOutputTokens: 1000, + totalCost: 0.0123, + totalElapsedMs: 5400, + }, + recentHistory: [ + { id: 'h-1', type: 'CUE', timestamp: Date.now(), summary: 'first', success: true }, + { id: 'h-2', type: 'CUE', timestamp: Date.now(), summary: 'second', success: false }, + ], + })); + + const interaction = makeInteraction({ + options: { + getSubcommand: () => 'show', + getString: (_name: string, _req: boolean) => 'agent-1', + }, + }); + + await execute(interaction); + + const reply = interaction.editReply.mock.calls[0].arguments[0]; + assert.ok(reply.embeds); + const data = reply.embeds[0].data; + assert.equal(data.title, 'TestBot'); + const fieldNames = data.fields.map((f: { name: string }) => f.name); + assert.ok(fieldNames.includes('Stats')); + assert.ok(fieldNames.includes('Recent activity')); +}); + +test('agents show clamps an oversize cwd value to the field-value limit', async () => { + const { maestro } = await import('../services/maestro'); + // 2000-char path comfortably exceeds the 1024 field limit (with backticks) + const longCwd = '/very/long/path/segment/'.repeat(100); + mock.method(maestro, 'showAgent', async () => ({ + id: 'agent-1', + name: 'TestBot', + toolType: 'claude', + cwd: longCwd, + })); + + const interaction = makeInteraction({ + options: { + getSubcommand: () => 'show', + getString: (_name: string, _req: boolean) => 'agent-1', + }, + }); + + await execute(interaction); + + const reply = interaction.editReply.mock.calls[0].arguments[0]; + const cwdField = reply.embeds[0].data.fields.find((f: { name: string }) => f.name === 'Cwd'); + assert.ok(cwdField, 'Cwd field should be present'); + assert.ok( + cwdField.value.length <= EMBED_FIELD_VALUE_MAX, + `Cwd field length ${cwdField.value.length} exceeds ${EMBED_FIELD_VALUE_MAX}`, + ); +}); + +test('agents show surfaces a friendly error when load fails', async () => { + const { maestro } = await import('../services/maestro'); + mock.method(maestro, 'showAgent', async () => { + throw new Error('agent missing'); + }); + + const interaction = makeInteraction({ + options: { + getSubcommand: () => 'show', + getString: (_name: string, _req: boolean) => 'agent-x', + }, + }); + + await execute(interaction); + + const reply = interaction.editReply.mock.calls[0].arguments[0]; + assert.equal(typeof reply, 'string'); + assert.ok(reply.includes('Could not load agent')); +}); + // --- /agents disconnect --- test('agents disconnect removes channel and schedules deletion', async () => { diff --git a/src/__tests__/embed.test.ts b/src/__tests__/embed.test.ts new file mode 100644 index 0000000..e49697f --- /dev/null +++ b/src/__tests__/embed.test.ts @@ -0,0 +1,36 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + EMBED_DESCRIPTION_MAX, + EMBED_FIELD_VALUE_MAX, + clampDescription, + clampFieldValue, + clampText, +} from '../utils/embed'; + +test('clampText returns input unchanged when within limit', () => { + assert.equal(clampText('hello', 10), 'hello'); + assert.equal(clampText('a'.repeat(10), 10), 'a'.repeat(10)); +}); + +test('clampText truncates and appends ellipsis marker when over limit', () => { + const out = clampText('a'.repeat(20), 10); + assert.equal(out.length, 10); + assert.ok(out.endsWith('\n…')); +}); + +test('clampText hard-slices when limit is shorter than ellipsis marker', () => { + assert.equal(clampText('hello', 1), 'h'); +}); + +test('clampDescription enforces the 4096 description limit', () => { + const huge = 'x'.repeat(EMBED_DESCRIPTION_MAX + 500); + const out = clampDescription(huge); + assert.equal(out.length, EMBED_DESCRIPTION_MAX); +}); + +test('clampFieldValue enforces the 1024 field-value limit', () => { + const huge = 'y'.repeat(EMBED_FIELD_VALUE_MAX + 500); + const out = clampFieldValue(huge); + assert.equal(out.length, EMBED_FIELD_VALUE_MAX); +}); diff --git a/src/__tests__/playbook-command.test.ts b/src/__tests__/playbook-command.test.ts new file mode 100644 index 0000000..a2a24ec --- /dev/null +++ b/src/__tests__/playbook-command.test.ts @@ -0,0 +1,109 @@ +import test, { afterEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { execute } from '../commands/playbook'; +import { EMBED_DESCRIPTION_MAX, EMBED_FIELD_VALUE_MAX } from '../utils/embed'; + +afterEach(() => { + mock.restoreAll(); +}); + +interface MockInteraction { + options: { + getSubcommand: () => string; + getString: (name: string, required?: boolean) => string | null; + }; + deferReply: ReturnType; + editReply: ReturnType; + reply: ReturnType; +} + +function makeInteraction(sub: string, options: Record = {}): MockInteraction { + return { + options: { + getSubcommand: () => sub, + getString: (name: string) => options[name] ?? null, + }, + deferReply: mock.fn(async () => {}), + editReply: mock.fn(async () => {}), + reply: mock.fn(async () => {}), + }; +} + +test('playbook list renders an embed with playbooks', async () => { + const { maestro } = await import('../services/maestro'); + mock.method(maestro, 'listPlaybooks', async () => [ + { + id: 'pb-1', + name: 'Build & Test', + description: '', + documentCount: 2, + taskCount: 7, + agentName: 'Alpha', + }, + ]); + + const i = makeInteraction('list'); + await execute(i as unknown as Parameters[0]); + + const reply = i.editReply.mock.calls[0].arguments[0] as { embeds: { data: { description: string } }[] }; + assert.ok(reply.embeds); + assert.ok(reply.embeds[0].data.description.includes('Build & Test')); + assert.ok(reply.embeds[0].data.description.includes('Alpha')); +}); + +test('playbook list shows a friendly message when no playbooks exist', async () => { + const { maestro } = await import('../services/maestro'); + mock.method(maestro, 'listPlaybooks', async () => []); + + const i = makeInteraction('list'); + await execute(i as unknown as Parameters[0]); + + const reply = i.editReply.mock.calls[0].arguments[0]; + assert.equal(typeof reply, 'string'); + assert.ok((reply as string).includes('No playbooks')); +}); + +test('playbook show clamps oversize description and document field', async () => { + const { maestro } = await import('../services/maestro'); + mock.method(maestro, 'showPlaybook', async () => ({ + id: 'pb-1', + name: 'Big Playbook', + description: 'd'.repeat(EMBED_DESCRIPTION_MAX + 1000), + documentCount: 30, + taskCount: 60, + documents: Array.from({ length: 15 }, (_, i) => ({ + path: '/very/long/path/segment/'.repeat(20) + `doc-${i}.md`, + taskCount: 5, + completedCount: 1, + })), + })); + + const i = makeInteraction('show', { playbook: 'pb-1' }); + await execute(i as unknown as Parameters[0]); + + type EmbedData = { + description: string; + fields: { name: string; value: string }[]; + }; + const reply = i.editReply.mock.calls[0].arguments[0] as { embeds: { data: EmbedData }[] }; + const data = reply.embeds[0].data; + assert.ok(data.description.length <= EMBED_DESCRIPTION_MAX); + + const docs = data.fields.find((f) => f.name === 'Documents'); + assert.ok(docs, 'Documents field should be present'); + assert.ok(docs!.value.length <= EMBED_FIELD_VALUE_MAX); +}); + +test('playbook show surfaces a friendly error when load fails', async () => { + const { maestro } = await import('../services/maestro'); + mock.method(maestro, 'showPlaybook', async () => { + throw new Error('not found'); + }); + + const i = makeInteraction('show', { playbook: 'pb-missing' }); + await execute(i as unknown as Parameters[0]); + + const reply = i.editReply.mock.calls[0].arguments[0]; + assert.equal(typeof reply, 'string'); + assert.ok((reply as string).includes('Could not load playbook')); +}); diff --git a/src/commands/agents.ts b/src/commands/agents.ts index 145abb5..685fa50 100644 --- a/src/commands/agents.ts +++ b/src/commands/agents.ts @@ -9,6 +9,7 @@ import { import { maestro } from '../services/maestro'; import { channelDb, threadDb } from '../db'; import { cleanupAgentFiles } from '../utils/attachments'; +import { clampFieldValue } from '../utils/embed'; import { config } from '../config'; function missingBotScopeMessage(): string { @@ -227,7 +228,7 @@ async function handleShow(interaction: ChatInputCommandInteraction): Promise clampText(text, EMBED_DESCRIPTION_MAX); +export const clampFieldValue = (text: string): string => clampText(text, EMBED_FIELD_VALUE_MAX); From 430494615694954edbb1dc3d0b9710a43a074bff Mon Sep 17 00:00:00 2001 From: chr1syy Date: Tue, 5 May 2026 07:33:30 +0200 Subject: [PATCH 04/31] fix(cli,commands): address PR #26 review feedback - cli/lib: add 5s timeout + single-settle guard to postToSendApi; new parsePort() helper that strictly validates --port (digits only, 1-65535) - cli/verbs/{send,notify,status}: use parsePort() - cli/verbs/status: split runMaestroCli execution from JSON.parse so each surfaces a distinct error - utils/embed: add EMBED_TITLE_MAX (256) + clampTitle() - commands/agents: clamp title and groupName in handleShow; bound channel name to Discord's 100-char limit; replace `as TextChannel` cast with isSendable() guard + friendly error reply - commands/auto-run: fetch autoRunFolderPath via showAgent (listAgents doesn't return it); resolve any non-absolute doc path against the agent folder, not only bare filenames - commands/gist: normalize non-Error rejection values before slicing - commands/playbook: clamp title and agent name in handleShow Tests: - new cli-lib.test.ts: parsePort validation + postToSendApi timeout, ECONNREFUSED, invalid-JSON, and double-settle protection - new auto-run-command.test.ts: bare filename, subdir path regression, absolute passthrough, showAgent failure, missing folder - new gist-command.test.ts: success, Error throw, string throw, object throw, long-message truncation - extend agents/playbook/embed tests with clamp + bound regressions Co-Authored-By: Claude Opus 4.7 (1M context) --- src/__tests__/agents-command.test.ts | 111 +++++++++++++- src/__tests__/auto-run-command.test.ts | 202 +++++++++++++++++++++++++ src/__tests__/cli-lib.test.ts | 132 ++++++++++++++++ src/__tests__/embed.test.ts | 13 ++ src/__tests__/gist-command.test.ts | 157 +++++++++++++++++++ src/__tests__/playbook-command.test.ts | 41 ++++- src/cli/lib.ts | 60 ++++++-- src/cli/verbs/notify.ts | 10 +- src/cli/verbs/send.ts | 10 +- src/cli/verbs/status.ts | 20 ++- src/commands/agents.ts | 22 ++- src/commands/auto-run.ts | 9 +- src/commands/gist.ts | 5 +- src/commands/playbook.ts | 6 +- src/utils/embed.ts | 2 + 15 files changed, 759 insertions(+), 41 deletions(-) create mode 100644 src/__tests__/auto-run-command.test.ts create mode 100644 src/__tests__/cli-lib.test.ts create mode 100644 src/__tests__/gist-command.test.ts diff --git a/src/__tests__/agents-command.test.ts b/src/__tests__/agents-command.test.ts index 8fec926..fc2440c 100644 --- a/src/__tests__/agents-command.test.ts +++ b/src/__tests__/agents-command.test.ts @@ -1,7 +1,7 @@ import test, { afterEach, mock } from 'node:test'; import assert from 'node:assert/strict'; import { execute, autocomplete } from '../commands/agents'; -import { EMBED_FIELD_VALUE_MAX } from '../utils/embed'; +import { EMBED_FIELD_VALUE_MAX, EMBED_TITLE_MAX } from '../utils/embed'; afterEach(() => { mock.restoreAll(); @@ -21,6 +21,7 @@ function makeInteraction(overrides: Record = {}) { create: mock.fn(async (opts: Record) => ({ id: 'new-ch-1', name: opts.name, + isSendable: () => true, send: mock.fn(async () => ({})), })), }, @@ -148,6 +149,79 @@ test('agents new requires a guild', async () => { assert.ok(reply.content.includes('must be used in a server')); }); +test('agents new bounds the channel name to Discord 100-char limit', async () => { + const { maestro } = await import('../services/maestro'); + // 200-char agent name will produce a > 100-char channel name (+ "agent-" prefix). + const longName = 'A'.repeat(200); + mock.method(maestro, 'listAgents', async () => [ + { id: 'agent-long', name: longName, toolType: 'claude', cwd: '/proj' }, + ]); + + const { channelDb } = await import('../db'); + mock.method(channelDb, 'register', () => {}); + + const interaction = makeInteraction({ + options: { + getSubcommand: () => 'new', + getString: (_name: string, _req: boolean) => 'agent-long', + }, + }); + + await execute(interaction); + + // create() is called twice: first for the "Maestro Agents" category, then for + // the actual agent channel. Find the call that targets the agent channel. + const calls = interaction.guild.channels.create.mock.calls; + const channelCall = calls.find((c: { arguments: [{ name: string }] }) => + c.arguments[0].name.startsWith('agent-'), + ); + assert.ok(channelCall, 'Expected a channel creation call starting with "agent-"'); + const passedName = channelCall.arguments[0].name as string; + assert.ok( + passedName.length <= 100, + `Channel name length ${passedName.length} exceeds Discord 100-char limit`, + ); + assert.ok(passedName.startsWith('agent-')); +}); + +test('agents new replies with a friendly error when channel is not sendable', async () => { + const { maestro } = await import('../services/maestro'); + mock.method(maestro, 'listAgents', async () => [ + { id: 'agent-abc', name: 'TestBot', toolType: 'claude', cwd: '/proj' }, + ]); + + const { channelDb } = await import('../db'); + const registerMock = mock.method(channelDb, 'register', () => {}); + + const interaction = makeInteraction({ + guild: { + id: 'guild-1', + channels: { + cache: { find: () => undefined }, + create: mock.fn(async (opts: Record) => ({ + id: 'new-ch-1', + name: opts.name, + // Simulate a non-sendable channel (e.g. permissions issue). + isSendable: () => false, + send: mock.fn(async () => ({})), + })), + }, + }, + options: { + getSubcommand: () => 'new', + getString: (_name: string, _req: boolean) => 'agent-abc', + }, + }); + + await execute(interaction); + + // Should not register the channel when not sendable. + assert.equal(registerMock.mock.callCount(), 0); + const reply = interaction.editReply.mock.calls[0].arguments[0]; + assert.equal(typeof reply, 'string'); + assert.ok(reply.includes('Failed to create a sendable channel')); +}); + test('agents new matches agent by prefix', async () => { const { maestro } = await import('../services/maestro'); mock.method(maestro, 'listAgents', async () => [ @@ -242,6 +316,41 @@ test('agents show clamps an oversize cwd value to the field-value limit', async ); }); +test('agents show clamps oversize title and groupName', async () => { + const { maestro } = await import('../services/maestro'); + const longName = 'N'.repeat(EMBED_TITLE_MAX + 500); + const longGroup = 'G'.repeat(EMBED_FIELD_VALUE_MAX + 500); + mock.method(maestro, 'showAgent', async () => ({ + id: 'agent-1', + name: longName, + toolType: 'claude', + cwd: '/proj', + groupName: longGroup, + })); + + const interaction = makeInteraction({ + options: { + getSubcommand: () => 'show', + getString: (_name: string, _req: boolean) => 'agent-1', + }, + }); + + await execute(interaction); + + const reply = interaction.editReply.mock.calls[0].arguments[0]; + const data = reply.embeds[0].data; + assert.ok( + data.title.length <= EMBED_TITLE_MAX, + `Title length ${data.title.length} exceeds ${EMBED_TITLE_MAX}`, + ); + const groupField = data.fields.find((f: { name: string }) => f.name === 'Group'); + assert.ok(groupField, 'Group field should be present'); + assert.ok( + groupField.value.length <= EMBED_FIELD_VALUE_MAX, + `Group field length ${groupField.value.length} exceeds ${EMBED_FIELD_VALUE_MAX}`, + ); +}); + test('agents show surfaces a friendly error when load fails', async () => { const { maestro } = await import('../services/maestro'); mock.method(maestro, 'showAgent', async () => { diff --git a/src/__tests__/auto-run-command.test.ts b/src/__tests__/auto-run-command.test.ts new file mode 100644 index 0000000..84dbd9c --- /dev/null +++ b/src/__tests__/auto-run-command.test.ts @@ -0,0 +1,202 @@ +import test, { afterEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'path'; +import { execute } from '../commands/auto-run'; + +afterEach(() => { + mock.restoreAll(); +}); + +interface MockInteraction { + channelId: string; + options: { + getSubcommand: () => string; + getString: (name: string, required?: boolean) => string | null; + getInteger: (name: string) => number | null; + getBoolean: (name: string) => boolean | null; + }; + deferReply: ReturnType; + editReply: ReturnType; + reply: ReturnType; +} + +function makeInteraction( + options: Record = {}, +): MockInteraction { + return { + channelId: 'ch-1', + options: { + getSubcommand: () => 'start', + getString: (name: string) => (options[name] as string | null) ?? null, + getInteger: (name: string) => (options[name] as number | null) ?? null, + getBoolean: (name: string) => (options[name] as boolean | null) ?? null, + }, + deferReply: mock.fn(async () => {}), + editReply: mock.fn(async () => {}), + reply: mock.fn(async () => {}), + }; +} + +test('auto-run start rejects channels not connected to an agent', async () => { + const { channelDb } = await import('../db'); + mock.method(channelDb, 'get', () => undefined); + + const i = makeInteraction({ doc: 'plan.md' }); + await execute(i as unknown as Parameters[0]); + + const reply = i.reply.mock.calls[0].arguments[0] as { content: string }; + assert.ok(reply.content.includes('not connected to an agent')); +}); + +test('auto-run start resolves a bare filename against the agent Auto Run folder', async () => { + const { channelDb } = await import('../db'); + mock.method(channelDb, 'get', () => ({ + channel_id: 'ch-1', + agent_id: 'agent-1', + agent_name: 'TestBot', + })); + + const { maestro } = await import('../services/maestro'); + mock.method(maestro, 'showAgent', async () => ({ + id: 'agent-1', + name: 'TestBot', + toolType: 'claude', + cwd: '/proj', + autoRunFolderPath: '/agents/auto-run-docs', + })); + + const startMock = mock.method(maestro, 'startAutoRun', async () => ''); + + const i = makeInteraction({ doc: 'plan.md' }); + await execute(i as unknown as Parameters[0]); + + assert.equal(startMock.mock.callCount(), 1); + const opts = startMock.mock.calls[0].arguments[0] as { docs: string[] }; + assert.deepEqual(opts.docs, [path.join('/agents/auto-run-docs', 'plan.md')]); +}); + +test('auto-run start resolves a relative subpath against the agent Auto Run folder', async () => { + const { channelDb } = await import('../db'); + mock.method(channelDb, 'get', () => ({ + channel_id: 'ch-1', + agent_id: 'agent-1', + agent_name: 'TestBot', + })); + + const { maestro } = await import('../services/maestro'); + mock.method(maestro, 'showAgent', async () => ({ + id: 'agent-1', + name: 'TestBot', + toolType: 'claude', + cwd: '/proj', + autoRunFolderPath: '/agents/auto-run-docs', + })); + + const startMock = mock.method(maestro, 'startAutoRun', async () => ''); + + const i = makeInteraction({ doc: 'subdir/doc.md' }); + await execute(i as unknown as Parameters[0]); + + assert.equal(startMock.mock.callCount(), 1); + const opts = startMock.mock.calls[0].arguments[0] as { docs: string[] }; + assert.deepEqual(opts.docs, [path.join('/agents/auto-run-docs', 'subdir/doc.md')]); +}); + +test('auto-run start preserves an absolute path verbatim', async () => { + const { channelDb } = await import('../db'); + mock.method(channelDb, 'get', () => ({ + channel_id: 'ch-1', + agent_id: 'agent-1', + agent_name: 'TestBot', + })); + + const { maestro } = await import('../services/maestro'); + // showAgent should not even be called when the path is absolute. + const showAgentMock = mock.method(maestro, 'showAgent', async () => { + throw new Error('should not be called'); + }); + const startMock = mock.method(maestro, 'startAutoRun', async () => ''); + + const i = makeInteraction({ doc: '/abs/path/doc.md' }); + await execute(i as unknown as Parameters[0]); + + assert.equal(showAgentMock.mock.callCount(), 0); + const opts = startMock.mock.calls[0].arguments[0] as { docs: string[] }; + assert.deepEqual(opts.docs, ['/abs/path/doc.md']); +}); + +test('auto-run start uses the doc as-is when showAgent fails to resolve a folder', async () => { + const { channelDb } = await import('../db'); + mock.method(channelDb, 'get', () => ({ + channel_id: 'ch-1', + agent_id: 'agent-1', + agent_name: 'TestBot', + })); + + const { maestro } = await import('../services/maestro'); + // showAgent throws β€” getAgentFolder should swallow and return null. + mock.method(maestro, 'showAgent', async () => { + throw new Error('cli unavailable'); + }); + const startMock = mock.method(maestro, 'startAutoRun', async () => ''); + + const i = makeInteraction({ doc: 'plan.md' }); + await execute(i as unknown as Parameters[0]); + + const opts = startMock.mock.calls[0].arguments[0] as { docs: string[] }; + assert.deepEqual(opts.docs, ['plan.md']); +}); + +test('auto-run start uses the doc as-is when autoRunFolderPath is missing', async () => { + const { channelDb } = await import('../db'); + mock.method(channelDb, 'get', () => ({ + channel_id: 'ch-1', + agent_id: 'agent-1', + agent_name: 'TestBot', + })); + + const { maestro } = await import('../services/maestro'); + mock.method(maestro, 'showAgent', async () => ({ + id: 'agent-1', + name: 'TestBot', + toolType: 'claude', + cwd: '/proj', + // autoRunFolderPath intentionally absent + })); + const startMock = mock.method(maestro, 'startAutoRun', async () => ''); + + const i = makeInteraction({ doc: 'plan.md' }); + await execute(i as unknown as Parameters[0]); + + const opts = startMock.mock.calls[0].arguments[0] as { docs: string[] }; + assert.deepEqual(opts.docs, ['plan.md']); +}); + +test('auto-run start surfaces errors from startAutoRun', async () => { + const { channelDb } = await import('../db'); + mock.method(channelDb, 'get', () => ({ + channel_id: 'ch-1', + agent_id: 'agent-1', + agent_name: 'TestBot', + })); + + const { maestro } = await import('../services/maestro'); + mock.method(maestro, 'showAgent', async () => ({ + id: 'agent-1', + name: 'TestBot', + toolType: 'claude', + cwd: '/proj', + autoRunFolderPath: '/agents/auto-run-docs', + })); + mock.method(maestro, 'startAutoRun', async () => { + throw new Error('boom'); + }); + + const i = makeInteraction({ doc: 'plan.md' }); + await execute(i as unknown as Parameters[0]); + + const reply = i.editReply.mock.calls[0].arguments[0]; + assert.equal(typeof reply, 'string'); + assert.ok((reply as string).includes('Auto Run failed to launch')); + assert.ok((reply as string).includes('boom')); +}); diff --git a/src/__tests__/cli-lib.test.ts b/src/__tests__/cli-lib.test.ts new file mode 100644 index 0000000..2aea5c7 --- /dev/null +++ b/src/__tests__/cli-lib.test.ts @@ -0,0 +1,132 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'http'; +import { AddressInfo } from 'net'; +import { DEFAULT_PORT, parsePort, postToSendApi } from '../cli/lib'; + +// --- parsePort --- + +test('parsePort returns the fallback when value is undefined', () => { + assert.equal(parsePort(undefined), DEFAULT_PORT); + assert.equal(parsePort(undefined, 9999), 9999); +}); + +test('parsePort accepts valid integer port strings', () => { + assert.equal(parsePort('1'), 1); + assert.equal(parsePort('80'), 80); + assert.equal(parsePort('3457'), 3457); + assert.equal(parsePort('65535'), 65535); +}); + +test('parsePort rejects values with non-digit characters', () => { + assert.throws(() => parsePort('123abc'), /must be an integer/); + assert.throws(() => parsePort('abc'), /must be an integer/); + assert.throws(() => parsePort(' 80'), /must be an integer/); + assert.throws(() => parsePort('80 '), /must be an integer/); + assert.throws(() => parsePort('-80'), /must be an integer/); + assert.throws(() => parsePort('80.5'), /must be an integer/); + assert.throws(() => parsePort('0x50'), /must be an integer/); +}); + +test('parsePort rejects empty string', () => { + assert.throws(() => parsePort(''), /must be an integer/); +}); + +test('parsePort rejects values out of TCP range', () => { + assert.throws(() => parsePort('0'), /1 and 65535/); + assert.throws(() => parsePort('65536'), /1 and 65535/); + assert.throws(() => parsePort('99999'), /1 and 65535/); +}); + +// --- postToSendApi --- + +test('postToSendApi resolves with parsed JSON on success', async () => { + const server = http.createServer((req, res) => { + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', () => { + const parsed = JSON.parse(body); + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ success: true, channelId: 'ch-' + parsed.agentId })); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + + try { + const result = await postToSendApi({ agentId: 'a-1', message: 'hi' }, port); + assert.equal(result.success, true); + assert.equal(result.channelId, 'ch-a-1'); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +test('postToSendApi rejects with timeout error when server stalls', async () => { + // A server that accepts the request but never responds. + const server = http.createServer(() => { + /* never calls res.end */ + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + + try { + await assert.rejects( + postToSendApi({ agentId: 'a-1', message: 'hi' }, port, 100), + /timed out/i, + ); + } finally { + // closeAllConnections required to free the stalled socket + server.closeAllConnections?.(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +test('postToSendApi reports a friendly message on ECONNREFUSED', async () => { + // Use a port that nothing is listening on. Picking 1 is reliably refused. + await assert.rejects( + postToSendApi({ agentId: 'a-1', message: 'hi' }, 1, 1000), + /not running|not started|ECONNREFUSED/i, + ); +}); + +test('postToSendApi rejects on invalid JSON response', async () => { + const server = http.createServer((_req, res) => { + res.end('not-json{{{'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + + try { + await assert.rejects( + postToSendApi({ agentId: 'a-1', message: 'hi' }, port), + /Invalid response from bot/, + ); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +test('postToSendApi only settles once when timeout fires', async () => { + // Server accepts but is slow; we set a tight timeout so the timer fires first, + // then the server eventually responds. The promise must already be settled. + const server = http.createServer((_req, res) => { + setTimeout(() => res.end(JSON.stringify({ success: true })), 200); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + + try { + await assert.rejects( + postToSendApi({ agentId: 'a-1', message: 'hi' }, port, 50), + /timed out/i, + ); + // Wait long enough for the slow server to attempt a response. + await new Promise((resolve) => setTimeout(resolve, 300)); + // If the timeout fix is wrong, the second resolve/reject would throw an + // UnhandledPromiseRejection here. Surviving the wait is the assertion. + } finally { + server.closeAllConnections?.(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); diff --git a/src/__tests__/embed.test.ts b/src/__tests__/embed.test.ts index e49697f..4bc1eb5 100644 --- a/src/__tests__/embed.test.ts +++ b/src/__tests__/embed.test.ts @@ -3,9 +3,11 @@ import assert from 'node:assert/strict'; import { EMBED_DESCRIPTION_MAX, EMBED_FIELD_VALUE_MAX, + EMBED_TITLE_MAX, clampDescription, clampFieldValue, clampText, + clampTitle, } from '../utils/embed'; test('clampText returns input unchanged when within limit', () => { @@ -34,3 +36,14 @@ test('clampFieldValue enforces the 1024 field-value limit', () => { const out = clampFieldValue(huge); assert.equal(out.length, EMBED_FIELD_VALUE_MAX); }); + +test('clampTitle enforces the 256 title limit', () => { + const huge = 'z'.repeat(EMBED_TITLE_MAX + 500); + const out = clampTitle(huge); + assert.equal(out.length, EMBED_TITLE_MAX); + assert.ok(out.endsWith('\n…')); +}); + +test('clampTitle leaves short titles unchanged', () => { + assert.equal(clampTitle('My Title'), 'My Title'); +}); diff --git a/src/__tests__/gist-command.test.ts b/src/__tests__/gist-command.test.ts new file mode 100644 index 0000000..835610b --- /dev/null +++ b/src/__tests__/gist-command.test.ts @@ -0,0 +1,157 @@ +import test, { afterEach, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { execute } from '../commands/gist'; + +afterEach(() => { + mock.restoreAll(); +}); + +interface MockInteraction { + channelId: string; + options: { + getString: (name: string, required?: boolean) => string | null; + getBoolean: (name: string) => boolean | null; + }; + deferReply: ReturnType; + editReply: ReturnType; + reply: ReturnType; +} + +function makeInteraction( + options: Record = {}, +): MockInteraction { + return { + channelId: 'ch-1', + options: { + getString: (name: string) => (options[name] as string | null) ?? null, + getBoolean: (name: string) => (options[name] as boolean | null) ?? null, + }, + deferReply: mock.fn(async () => {}), + editReply: mock.fn(async () => {}), + reply: mock.fn(async () => {}), + }; +} + +test('gist rejects channels not connected to an agent', async () => { + const { channelDb } = await import('../db'); + mock.method(channelDb, 'get', () => undefined); + + const i = makeInteraction(); + await execute(i as unknown as Parameters[0]); + + const reply = i.reply.mock.calls[0].arguments[0] as { content: string }; + assert.ok(reply.content.includes('not connected to an agent')); +}); + +test('gist publishes and renders an embed with the gist url', async () => { + const { channelDb } = await import('../db'); + mock.method(channelDb, 'get', () => ({ + channel_id: 'ch-1', + agent_id: 'agent-1', + agent_name: 'TestBot', + })); + + const { maestro } = await import('../services/maestro'); + mock.method(maestro, 'createGist', async () => ({ + url: 'https://gist.example/abc', + id: 'abc', + })); + + const i = makeInteraction({ description: 'desc', public: true }); + await execute(i as unknown as Parameters[0]); + + const reply = i.editReply.mock.calls[0].arguments[0] as { + embeds: { data: { url: string; title: string; description: string } }[]; + }; + assert.equal(reply.embeds[0].data.url, 'https://gist.example/abc'); + assert.ok(reply.embeds[0].data.title.includes('TestBot')); + assert.ok(reply.embeds[0].data.description.includes('public')); +}); + +test('gist surfaces a friendly error when createGist throws an Error', async () => { + const { channelDb } = await import('../db'); + mock.method(channelDb, 'get', () => ({ + channel_id: 'ch-1', + agent_id: 'agent-1', + agent_name: 'TestBot', + })); + + const { maestro } = await import('../services/maestro'); + mock.method(maestro, 'createGist', async () => { + throw new Error('gh not authenticated'); + }); + + const i = makeInteraction(); + await execute(i as unknown as Parameters[0]); + + const reply = i.editReply.mock.calls[0].arguments[0]; + assert.equal(typeof reply, 'string'); + assert.ok((reply as string).includes('Could not publish gist')); + assert.ok((reply as string).includes('gh not authenticated')); +}); + +test('gist tolerates non-Error throws (string)', async () => { + const { channelDb } = await import('../db'); + mock.method(channelDb, 'get', () => ({ + channel_id: 'ch-1', + agent_id: 'agent-1', + agent_name: 'TestBot', + })); + + const { maestro } = await import('../services/maestro'); + // Reject with a non-Error value β€” must not blow up the catch handler. + mock.method(maestro, 'createGist', async () => { + throw 'plain string failure'; + }); + + const i = makeInteraction(); + await execute(i as unknown as Parameters[0]); + + const reply = i.editReply.mock.calls[0].arguments[0]; + assert.equal(typeof reply, 'string'); + assert.ok((reply as string).includes('Could not publish gist')); + assert.ok((reply as string).includes('plain string failure')); +}); + +test('gist tolerates non-Error throws (object without message)', async () => { + const { channelDb } = await import('../db'); + mock.method(channelDb, 'get', () => ({ + channel_id: 'ch-1', + agent_id: 'agent-1', + agent_name: 'TestBot', + })); + + const { maestro } = await import('../services/maestro'); + mock.method(maestro, 'createGist', async () => { + throw { code: 42 }; + }); + + const i = makeInteraction(); + await execute(i as unknown as Parameters[0]); + + const reply = i.editReply.mock.calls[0].arguments[0]; + assert.equal(typeof reply, 'string'); + assert.ok((reply as string).includes('Could not publish gist')); +}); + +test('gist truncates very long error messages to 1500 chars', async () => { + const { channelDb } = await import('../db'); + mock.method(channelDb, 'get', () => ({ + channel_id: 'ch-1', + agent_id: 'agent-1', + agent_name: 'TestBot', + })); + + const { maestro } = await import('../services/maestro'); + const huge = 'x'.repeat(5000); + mock.method(maestro, 'createGist', async () => { + throw new Error(huge); + }); + + const i = makeInteraction(); + await execute(i as unknown as Parameters[0]); + + const reply = i.editReply.mock.calls[0].arguments[0] as string; + // header text "❌ Could not publish gist: " is added on top of 1500 chars + assert.ok(reply.length <= 1500 + 50); +}); diff --git a/src/__tests__/playbook-command.test.ts b/src/__tests__/playbook-command.test.ts index a2a24ec..401d2eb 100644 --- a/src/__tests__/playbook-command.test.ts +++ b/src/__tests__/playbook-command.test.ts @@ -1,7 +1,11 @@ import test, { afterEach, mock } from 'node:test'; import assert from 'node:assert/strict'; import { execute } from '../commands/playbook'; -import { EMBED_DESCRIPTION_MAX, EMBED_FIELD_VALUE_MAX } from '../utils/embed'; +import { + EMBED_DESCRIPTION_MAX, + EMBED_FIELD_VALUE_MAX, + EMBED_TITLE_MAX, +} from '../utils/embed'; afterEach(() => { mock.restoreAll(); @@ -94,6 +98,41 @@ test('playbook show clamps oversize description and document field', async () => assert.ok(docs!.value.length <= EMBED_FIELD_VALUE_MAX); }); +test('playbook show clamps oversize title and agent name', async () => { + const { maestro } = await import('../services/maestro'); + const longName = 'P'.repeat(EMBED_TITLE_MAX + 500); + const longAgent = 'A'.repeat(EMBED_FIELD_VALUE_MAX + 500); + mock.method(maestro, 'showPlaybook', async () => ({ + id: 'pb-1', + name: longName, + description: 'short', + documentCount: 1, + taskCount: 1, + agentName: longAgent, + documents: [], + })); + + const i = makeInteraction('show', { playbook: 'pb-1' }); + await execute(i as unknown as Parameters[0]); + + type EmbedData = { + title: string; + fields: { name: string; value: string }[]; + }; + const reply = i.editReply.mock.calls[0].arguments[0] as { embeds: { data: EmbedData }[] }; + const data = reply.embeds[0].data; + assert.ok( + data.title.length <= EMBED_TITLE_MAX, + `Title length ${data.title.length} exceeds ${EMBED_TITLE_MAX}`, + ); + const agentField = data.fields.find((f) => f.name === 'Agent'); + assert.ok(agentField, 'Agent field should be present'); + assert.ok( + agentField!.value.length <= EMBED_FIELD_VALUE_MAX, + `Agent field length ${agentField!.value.length} exceeds ${EMBED_FIELD_VALUE_MAX}`, + ); +}); + test('playbook show surfaces a friendly error when load fails', async () => { const { maestro } = await import('../services/maestro'); mock.method(maestro, 'showPlaybook', async () => { diff --git a/src/cli/lib.ts b/src/cli/lib.ts index 04adf32..2e3ea93 100644 --- a/src/cli/lib.ts +++ b/src/cli/lib.ts @@ -17,11 +17,23 @@ export interface SendApiResult { } export const DEFAULT_PORT = 3457; +export const DEFAULT_REQUEST_TIMEOUT_MS = 5000; -export function postToSendApi(payload: SendApiPayload, port: number): Promise { +export function postToSendApi( + payload: SendApiPayload, + port: number, + timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS, +): Promise { const body = JSON.stringify(payload); return new Promise((resolve, reject) => { + let settled = false; + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + fn(); + }; + const req = http.request( { hostname: '127.0.0.1', @@ -37,21 +49,31 @@ export function postToSendApi(payload: SendApiPayload, port: number): Promise (chunks += c)); res.on('end', () => { - try { - resolve(JSON.parse(chunks) as SendApiResult); - } catch { - reject(new Error('Invalid response from bot')); - } + settle(() => { + try { + resolve(JSON.parse(chunks) as SendApiResult); + } catch { + reject(new Error('Invalid response from bot')); + } + }); }); }, ); + req.setTimeout(timeoutMs, () => { + const err = new Error(`Request to bot timed out after ${timeoutMs}ms`); + req.destroy(err); + settle(() => reject(err)); + }); + req.on('error', (err) => { - if ((err as NodeJS.ErrnoException).code === 'ECONNREFUSED') { - reject(new Error('Bot is not running or API server is not started')); - } else { - reject(err); - } + settle(() => { + if ((err as NodeJS.ErrnoException).code === 'ECONNREFUSED') { + reject(new Error('Bot is not running or API server is not started')); + } else { + reject(err); + } + }); }); req.write(body); @@ -59,6 +81,22 @@ export function postToSendApi(payload: SendApiPayload, port: number): Promise 65535) { + throw new Error('--port must be an integer between 1 and 65535'); + } + return port; +} + export async function runMaestroCli(args: string[], timeoutMs = 10_000): Promise { const { stdout } = await execFileAsync('maestro-cli', args, { timeout: timeoutMs, diff --git a/src/cli/verbs/notify.ts b/src/cli/verbs/notify.ts index b276c9c..45f0ad9 100644 --- a/src/cli/verbs/notify.ts +++ b/src/cli/verbs/notify.ts @@ -1,5 +1,5 @@ import { parseArgs } from 'node:util'; -import { DEFAULT_PORT, fail, ok, postToSendApi } from '../lib'; +import { DEFAULT_PORT, fail, ok, parsePort, postToSendApi } from '../lib'; export const notifyUsage = `Usage: maestro-discord notify [options] @@ -76,8 +76,12 @@ export async function runNotify(argv: string[]): Promise { const agentId = parsed.values.agent; if (!agentId) fail('--agent is required'); - const port = parsed.values.port ? parseInt(parsed.values.port, 10) : DEFAULT_PORT; - if (Number.isNaN(port)) fail('--port must be a number'); + let port: number; + try { + port = parsePort(parsed.values.port); + } catch (err) { + fail((err as Error).message); + } let content: string; if (sub === 'toast') { diff --git a/src/cli/verbs/send.ts b/src/cli/verbs/send.ts index d981f49..323f349 100644 --- a/src/cli/verbs/send.ts +++ b/src/cli/verbs/send.ts @@ -1,5 +1,5 @@ import { parseArgs } from 'node:util'; -import { DEFAULT_PORT, fail, ok, postToSendApi } from '../lib'; +import { DEFAULT_PORT, fail, ok, parsePort, postToSendApi } from '../lib'; export const sendUsage = `Usage: maestro-discord send --agent --message [--mention] [--port ] @@ -44,8 +44,12 @@ export async function runSend(argv: string[]): Promise { fail('--agent and --message are required'); } - const port = parsed.values.port ? parseInt(parsed.values.port, 10) : DEFAULT_PORT; - if (Number.isNaN(port)) fail('--port must be a number'); + let port: number; + try { + port = parsePort(parsed.values.port); + } catch (err) { + fail((err as Error).message); + } try { const result = await postToSendApi( diff --git a/src/cli/verbs/status.ts b/src/cli/verbs/status.ts index bd9b284..f65675d 100644 --- a/src/cli/verbs/status.ts +++ b/src/cli/verbs/status.ts @@ -1,5 +1,5 @@ import { parseArgs } from 'node:util'; -import { DEFAULT_PORT, fail, ok, postToSendApi, runMaestroCli } from '../lib'; +import { DEFAULT_PORT, fail, ok, parsePort, postToSendApi, runMaestroCli } from '../lib'; export const statusUsage = `Usage: maestro-discord status --agent [--port ] @@ -82,15 +82,25 @@ export async function runStatus(argv: string[]): Promise { fail('--agent is required'); } - const port = parsed.values.port ? parseInt(parsed.values.port, 10) : DEFAULT_PORT; - if (Number.isNaN(port)) fail('--port must be a number'); + let port: number; + try { + port = parsePort(parsed.values.port); + } catch (err) { + fail((err as Error).message); + } + + let raw: string; + try { + raw = await runMaestroCli(['show', 'agent', agentId, '--json']); + } catch (err) { + fail(`maestro-cli show agent failed: ${(err as Error).message}`); + } let detail: AgentDetail; try { - const raw = await runMaestroCli(['show', 'agent', agentId, '--json']); detail = JSON.parse(raw) as AgentDetail; } catch (err) { - fail(`maestro-cli show agent failed: ${(err as Error).message}`); + fail(`Invalid JSON from maestro-cli show agent: ${(err as Error).message}`); } try { diff --git a/src/commands/agents.ts b/src/commands/agents.ts index 685fa50..6be0016 100644 --- a/src/commands/agents.ts +++ b/src/commands/agents.ts @@ -9,7 +9,7 @@ import { import { maestro } from '../services/maestro'; import { channelDb, threadDb } from '../db'; import { cleanupAgentFiles } from '../utils/attachments'; -import { clampFieldValue } from '../utils/embed'; +import { clampFieldValue, clampTitle } from '../utils/embed'; import { config } from '../config'; function missingBotScopeMessage(): string { @@ -187,13 +187,23 @@ async function handleNew(interaction: ChatInputCommandInteraction): Promise { try { - const agents = await maestro.listAgents(); - const agent = agents.find((a) => a.id === agentId); - const folder = agent && (agent as { autoRunFolderPath?: unknown }).autoRunFolderPath; + const agent = await maestro.showAgent(agentId); + const folder = agent.autoRunFolderPath; return typeof folder === 'string' ? folder : null; } catch { return null; @@ -102,9 +101,9 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise const resetOnCompletion = interaction.options.getBoolean('reset_on_completion') ?? undefined; - // If the user typed a bare filename, resolve it against the agent's Auto Run folder. + // Resolve any relative path (filename or subpath) against the agent's Auto Run folder. let docPath = doc; - if (!path.isAbsolute(doc) && !doc.includes('/')) { + if (!path.isAbsolute(doc)) { const folder = await getAgentFolder(channelInfo.agent_id); if (folder) docPath = path.join(folder, doc); } diff --git a/src/commands/gist.ts b/src/commands/gist.ts index e8af9df..08d5eaf 100644 --- a/src/commands/gist.ts +++ b/src/commands/gist.ts @@ -38,9 +38,8 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise try { result = await maestro.createGist(channelInfo.agent_id, { description, isPublic }); } catch (err) { - await interaction.editReply( - `❌ Could not publish gist: ${(err as Error).message.slice(0, 1500)}`, - ); + const message = err instanceof Error ? err.message : String(err); + await interaction.editReply(`❌ Could not publish gist: ${message.slice(0, 1500)}`); return; } diff --git a/src/commands/playbook.ts b/src/commands/playbook.ts index b560ee1..6e7cc8f 100644 --- a/src/commands/playbook.ts +++ b/src/commands/playbook.ts @@ -5,7 +5,7 @@ import { SlashCommandBuilder, } from 'discord.js'; import { maestro } from '../services/maestro'; -import { clampDescription, clampFieldValue } from '../utils/embed'; +import { clampDescription, clampFieldValue, clampTitle } from '../utils/embed'; export const data = new SlashCommandBuilder() .setName('playbook') @@ -156,7 +156,7 @@ async function handleShow(interaction: ChatInputCommandInteraction): Promise clampText(text, EMBED_TITLE_MAX); export const clampDescription = (text: string): string => clampText(text, EMBED_DESCRIPTION_MAX); export const clampFieldValue = (text: string): string => clampText(text, EMBED_FIELD_VALUE_MAX); From ffca9f56eeb57edc49ece1ccca01d850618cddb3 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Tue, 5 May 2026 08:31:11 +0200 Subject: [PATCH 05/31] fix(cli,commands,tests): address PR #26 follow-up review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agents: drop the unsafe `as TextChannel` cast (and import) since isSendable() already narrows the channel type - tests/auto-run: add callCount guards before reading mock.calls[0] in absolute-path / showAgent-fail / missing-folder tests so a regression produces a clean assertion failure rather than a TypeError - tests/cli-lib: drop the port-1 ECONNREFUSED trick (unreliable on Windows). Bind a server to a random free port, close it immediately, then connect β€” the OS keeps the port reserved long enough to refuse predictably across platforms - tests/gist: tighten truncation test with a lower-bound assertion and prefix check so over-truncation regressions don't slip through Co-Authored-By: Claude Opus 4.7 (1M context) --- src/__tests__/auto-run-command.test.ts | 3 +++ src/__tests__/cli-lib.test.ts | 11 +++++++++-- src/__tests__/gist-command.test.ts | 7 +++++++ src/commands/agents.ts | 3 +-- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/__tests__/auto-run-command.test.ts b/src/__tests__/auto-run-command.test.ts index 84dbd9c..f20defa 100644 --- a/src/__tests__/auto-run-command.test.ts +++ b/src/__tests__/auto-run-command.test.ts @@ -121,6 +121,7 @@ test('auto-run start preserves an absolute path verbatim', async () => { await execute(i as unknown as Parameters[0]); assert.equal(showAgentMock.mock.callCount(), 0); + assert.equal(startMock.mock.callCount(), 1); const opts = startMock.mock.calls[0].arguments[0] as { docs: string[] }; assert.deepEqual(opts.docs, ['/abs/path/doc.md']); }); @@ -143,6 +144,7 @@ test('auto-run start uses the doc as-is when showAgent fails to resolve a folder const i = makeInteraction({ doc: 'plan.md' }); await execute(i as unknown as Parameters[0]); + assert.equal(startMock.mock.callCount(), 1); const opts = startMock.mock.calls[0].arguments[0] as { docs: string[] }; assert.deepEqual(opts.docs, ['plan.md']); }); @@ -168,6 +170,7 @@ test('auto-run start uses the doc as-is when autoRunFolderPath is missing', asyn const i = makeInteraction({ doc: 'plan.md' }); await execute(i as unknown as Parameters[0]); + assert.equal(startMock.mock.callCount(), 1); const opts = startMock.mock.calls[0].arguments[0] as { docs: string[] }; assert.deepEqual(opts.docs, ['plan.md']); }); diff --git a/src/__tests__/cli-lib.test.ts b/src/__tests__/cli-lib.test.ts index 2aea5c7..41d9ca6 100644 --- a/src/__tests__/cli-lib.test.ts +++ b/src/__tests__/cli-lib.test.ts @@ -83,9 +83,16 @@ test('postToSendApi rejects with timeout error when server stalls', async () => }); test('postToSendApi reports a friendly message on ECONNREFUSED', async () => { - // Use a port that nothing is listening on. Picking 1 is reliably refused. + // Bind a server to a random free port, then close it immediately. The OS + // won't reassign the port instantly, so connecting to it gets refused + // predictably across platforms (port 1 isn't reliably refused on Windows). + const probe = http.createServer(); + await new Promise((resolve) => probe.listen(0, '127.0.0.1', resolve)); + const port = (probe.address() as AddressInfo).port; + await new Promise((resolve) => probe.close(() => resolve())); + await assert.rejects( - postToSendApi({ agentId: 'a-1', message: 'hi' }, 1, 1000), + postToSendApi({ agentId: 'a-1', message: 'hi' }, port, 1000), /not running|not started|ECONNREFUSED/i, ); }); diff --git a/src/__tests__/gist-command.test.ts b/src/__tests__/gist-command.test.ts index 835610b..ea0ebdc 100644 --- a/src/__tests__/gist-command.test.ts +++ b/src/__tests__/gist-command.test.ts @@ -154,4 +154,11 @@ test('gist truncates very long error messages to 1500 chars', async () => { const reply = i.editReply.mock.calls[0].arguments[0] as string; // header text "❌ Could not publish gist: " is added on top of 1500 chars assert.ok(reply.length <= 1500 + 50); + // Lower bound catches over-truncation regressions: the body must still + // contain ~1500 chars of the original error. + assert.ok( + reply.length >= 1500, + `reply length ${reply.length} indicates over-truncation`, + ); + assert.ok(reply.startsWith('❌ Could not publish gist:')); }); diff --git a/src/commands/agents.ts b/src/commands/agents.ts index 6be0016..0ba3e0b 100644 --- a/src/commands/agents.ts +++ b/src/commands/agents.ts @@ -4,7 +4,6 @@ import { SlashCommandBuilder, EmbedBuilder, ChannelType, - TextChannel, } from 'discord.js'; import { maestro } from '../services/maestro'; import { channelDb, threadDb } from '../db'; @@ -203,7 +202,7 @@ async function handleNew(interaction: ChatInputCommandInteraction): Promise Date: Tue, 5 May 2026 11:18:59 +0200 Subject: [PATCH 06/31] refactor(core,providers): extract provider-agnostic kernel and Discord adapter Restructure the codebase into src/core/ (provider-agnostic kernel) and src/providers/discord/ (adapter implementing a new BridgeProvider interface). All Discord-specific code now lives behind the adapter; the kernel speaks only in IncomingMessage / ConversationRecord / ChannelTarget types. A new provider can be added by dropping src/providers// without touching the kernel. Behavior preserved end-to-end: - All 7 slash commands, voice transcription, and HTTP /api/send work identically from the user's perspective. - All env vars unchanged (DISCORD_*, API_PORT, WHISPER_*); new optional ENABLED_PROVIDERS defaults to 'discord'. - /api/send accepts an optional `provider` field (defaults to 'discord'). - DB schema upgrades automatically on first start: agent_channels gets a `provider` column and composite PK (provider, channel_id); agent_threads is renamed to discord_agent_threads with rows preserved. - CLI binary maestro-discord unchanged. Tests: 158/158 pass (151 baseline + 7 new). Includes a MockProvider smoke test exercising the BridgeProvider interface end-to-end without any Discord code, and a legacy-schema-upgrade migration test. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- src/__tests__/agents-command.test.ts | 53 +-- src/__tests__/attachments.test.ts | 77 ++--- src/__tests__/auto-run-command.test.ts | 28 +- src/__tests__/config.test.ts | 36 +- src/__tests__/db-migrations.test.ts | 93 +++++- src/__tests__/db.test.ts | 3 +- src/__tests__/embed.test.ts | 2 +- src/__tests__/getAgentCwd.test.ts | 4 +- src/__tests__/gist-command.test.ts | 24 +- src/__tests__/messageCreate.test.ts | 27 +- src/__tests__/mockProvider.test.ts | 110 +++++++ src/__tests__/playbook-command.test.ts | 14 +- src/__tests__/queue.test.ts | 305 +++++++++-------- src/__tests__/server.test.ts | 213 ++++++------ src/__tests__/session-command.test.ts | 33 +- src/__tests__/splitMessage.test.ts | 4 +- src/__tests__/transcription.test.ts | 7 +- src/commands/.gitkeep | 0 src/core/api.ts | 223 +++++++++++++ src/{utils => core}/attachments.ts | 48 ++- src/{ => core}/config.ts | 26 +- src/core/db/index.ts | 97 ++++++ src/core/db/migrations.ts | 121 +++++++ src/{services => core}/logger.ts | 0 src/{services => core}/maestro.ts | 0 src/core/providers.ts | 29 ++ src/core/queue.ts | 227 +++++++++++++ src/core/splitMessage.ts | 23 ++ src/{services => core}/transcription.ts | 66 ++-- src/core/types.ts | 123 +++++++ src/db/index.ts | 145 -------- src/db/migrations.ts | 27 -- src/handlers/.gitkeep | 0 src/index.ts | 154 +++------ src/providers/discord/adapter.ts | 276 ++++++++++++++++ src/providers/discord/channelsDb.ts | 34 ++ .../discord}/commands/agents.ts | 13 +- .../discord}/commands/auto-run.ts | 4 +- src/{ => providers/discord}/commands/gist.ts | 4 +- .../discord}/commands/health.ts | 2 +- src/{ => providers/discord}/commands/notes.ts | 2 +- .../discord}/commands/playbook.ts | 4 +- .../discord}/commands/session.ts | 5 +- src/providers/discord/config.ts | 33 ++ .../discord/deploy.ts} | 11 +- src/{utils => providers/discord}/embed.ts | 0 .../discord}/messageCreate.ts | 122 +++---- src/providers/discord/threadsDb.ts | 54 +++ src/providers/discord/voice.ts | 23 ++ src/server.ts | 310 ------------------ src/services/.gitkeep | 0 src/services/queue.ts | 21 -- src/services/queueFactory.ts | 215 ------------ src/utils/splitMessage.ts | 24 -- 55 files changed, 2085 insertions(+), 1416 deletions(-) create mode 100644 src/__tests__/mockProvider.test.ts delete mode 100644 src/commands/.gitkeep create mode 100644 src/core/api.ts rename src/{utils => core}/attachments.ts (63%) rename src/{ => core}/config.ts (60%) create mode 100644 src/core/db/index.ts create mode 100644 src/core/db/migrations.ts rename src/{services => core}/logger.ts (100%) rename src/{services => core}/maestro.ts (100%) create mode 100644 src/core/providers.ts create mode 100644 src/core/queue.ts create mode 100644 src/core/splitMessage.ts rename src/{services => core}/transcription.ts (72%) create mode 100644 src/core/types.ts delete mode 100644 src/db/index.ts delete mode 100644 src/db/migrations.ts delete mode 100644 src/handlers/.gitkeep create mode 100644 src/providers/discord/adapter.ts create mode 100644 src/providers/discord/channelsDb.ts rename src/{ => providers/discord}/commands/agents.ts (96%) rename src/{ => providers/discord}/commands/auto-run.ts (97%) rename src/{ => providers/discord}/commands/gist.ts (94%) rename src/{ => providers/discord}/commands/health.ts (95%) rename src/{ => providers/discord}/commands/notes.ts (98%) rename src/{ => providers/discord}/commands/playbook.ts (99%) rename src/{ => providers/discord}/commands/session.ts (96%) create mode 100644 src/providers/discord/config.ts rename src/{deploy-commands.ts => providers/discord/deploy.ts} (73%) rename src/{utils => providers/discord}/embed.ts (100%) rename src/{handlers => providers/discord}/messageCreate.ts (69%) create mode 100644 src/providers/discord/threadsDb.ts create mode 100644 src/providers/discord/voice.ts delete mode 100644 src/server.ts delete mode 100644 src/services/.gitkeep delete mode 100644 src/services/queue.ts delete mode 100644 src/services/queueFactory.ts delete mode 100644 src/utils/splitMessage.ts diff --git a/package.json b/package.json index 815a696..00c7da1 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "dev": "tsx src/index.ts", "build": "tsc", "start": "node dist/index.js", - "deploy-commands": "tsx src/deploy-commands.ts", + "deploy-commands": "tsx src/providers/discord/deploy.ts", "test": "npm run build && node --test dist/__tests__/**/*.test.js", "maestro-discord": "tsx src/cli/maestro-discord.ts" }, diff --git a/src/__tests__/agents-command.test.ts b/src/__tests__/agents-command.test.ts index fc2440c..ce650d5 100644 --- a/src/__tests__/agents-command.test.ts +++ b/src/__tests__/agents-command.test.ts @@ -1,7 +1,7 @@ import test, { afterEach, mock } from 'node:test'; import assert from 'node:assert/strict'; -import { execute, autocomplete } from '../commands/agents'; -import { EMBED_FIELD_VALUE_MAX, EMBED_TITLE_MAX } from '../utils/embed'; +import { execute, autocomplete } from '../providers/discord/commands/agents'; +import { EMBED_FIELD_VALUE_MAX, EMBED_TITLE_MAX } from '../providers/discord/embed'; afterEach(() => { mock.restoreAll(); @@ -42,7 +42,7 @@ function makeInteraction(overrides: Record = {}) { // --- /agents list --- test('agents list shows agents in an embed', async () => { - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); mock.method(maestro, 'listAgents', async () => [ { id: 'a-1', name: 'Alpha', toolType: 'claude', cwd: '/home' }, { id: 'a-2', name: 'Beta', toolType: 'openai', cwd: '/work' }, @@ -67,7 +67,7 @@ test('agents list shows agents in an embed', async () => { }); test('agents list shows message when no agents found', async () => { - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); mock.method(maestro, 'listAgents', async () => []); const interaction = makeInteraction({ @@ -84,12 +84,12 @@ test('agents list shows message when no agents found', async () => { // --- /agents new --- test('agents new creates a channel for a valid agent', async () => { - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); mock.method(maestro, 'listAgents', async () => [ { id: 'agent-abc', name: 'TestBot', toolType: 'claude', cwd: '/proj' }, ]); - const { channelDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); const registerMock = mock.method(channelDb, 'register', () => {}); const interaction = makeInteraction({ @@ -112,7 +112,7 @@ test('agents new creates a channel for a valid agent', async () => { }); test('agents new rejects unknown agent', async () => { - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); mock.method(maestro, 'listAgents', async () => [ { id: 'other-agent', name: 'Other', toolType: 'claude', cwd: '/' }, ]); @@ -132,7 +132,7 @@ test('agents new rejects unknown agent', async () => { }); test('agents new requires a guild', async () => { - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); mock.method(maestro, 'listAgents', async () => []); const interaction = makeInteraction({ @@ -150,14 +150,14 @@ test('agents new requires a guild', async () => { }); test('agents new bounds the channel name to Discord 100-char limit', async () => { - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); // 200-char agent name will produce a > 100-char channel name (+ "agent-" prefix). const longName = 'A'.repeat(200); mock.method(maestro, 'listAgents', async () => [ { id: 'agent-long', name: longName, toolType: 'claude', cwd: '/proj' }, ]); - const { channelDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); mock.method(channelDb, 'register', () => {}); const interaction = makeInteraction({ @@ -185,12 +185,12 @@ test('agents new bounds the channel name to Discord 100-char limit', async () => }); test('agents new replies with a friendly error when channel is not sendable', async () => { - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); mock.method(maestro, 'listAgents', async () => [ { id: 'agent-abc', name: 'TestBot', toolType: 'claude', cwd: '/proj' }, ]); - const { channelDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); const registerMock = mock.method(channelDb, 'register', () => {}); const interaction = makeInteraction({ @@ -223,12 +223,12 @@ test('agents new replies with a friendly error when channel is not sendable', as }); test('agents new matches agent by prefix', async () => { - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); mock.method(maestro, 'listAgents', async () => [ { id: 'agent-abc-123-full', name: 'PrefixBot', toolType: 'claude', cwd: '/proj' }, ]); - const { channelDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); mock.method(channelDb, 'register', () => {}); const interaction = makeInteraction({ @@ -247,7 +247,7 @@ test('agents new matches agent by prefix', async () => { // --- /agents show --- test('agents show renders an embed with stats and recent activity', async () => { - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); mock.method(maestro, 'showAgent', async () => ({ id: 'agent-1', name: 'TestBot', @@ -288,7 +288,7 @@ test('agents show renders an embed with stats and recent activity', async () => }); test('agents show clamps an oversize cwd value to the field-value limit', async () => { - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); // 2000-char path comfortably exceeds the 1024 field limit (with backticks) const longCwd = '/very/long/path/segment/'.repeat(100); mock.method(maestro, 'showAgent', async () => ({ @@ -317,7 +317,7 @@ test('agents show clamps an oversize cwd value to the field-value limit', async }); test('agents show clamps oversize title and groupName', async () => { - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); const longName = 'N'.repeat(EMBED_TITLE_MAX + 500); const longGroup = 'G'.repeat(EMBED_FIELD_VALUE_MAX + 500); mock.method(maestro, 'showAgent', async () => ({ @@ -352,7 +352,7 @@ test('agents show clamps oversize title and groupName', async () => { }); test('agents show surfaces a friendly error when load fails', async () => { - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); mock.method(maestro, 'showAgent', async () => { throw new Error('agent missing'); }); @@ -374,7 +374,8 @@ test('agents show surfaces a friendly error when load fails', async () => { // --- /agents disconnect --- test('agents disconnect removes channel and schedules deletion', async () => { - const { channelDb, threadDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); + const { threadDb } = await import('../providers/discord/threadsDb'); mock.method(channelDb, 'get', () => ({ channel_id: 'ch-1', agent_id: 'agent-1', @@ -385,7 +386,7 @@ test('agents disconnect removes channel and schedules deletion', async () => { const removeThreadsMock = mock.method(threadDb, 'removeByChannel', () => {}); mock.method(threadDb, 'getByAgentId', () => []); - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); // Return null so cleanupAgentFiles is never called (no real side effects) mock.method(maestro, 'getAgentCwd', async () => null); @@ -403,7 +404,7 @@ test('agents disconnect removes channel and schedules deletion', async () => { }); test('agents disconnect rejects non-agent channels', async () => { - const { channelDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); mock.method(channelDb, 'get', () => undefined); const interaction = makeInteraction({ @@ -419,7 +420,7 @@ test('agents disconnect rejects non-agent channels', async () => { // --- /agents readonly --- test('agents readonly on sets read-only mode', async () => { - const { channelDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); mock.method(channelDb, 'get', () => ({ channel_id: 'ch-1', agent_name: 'TestBot', @@ -448,7 +449,7 @@ test('agents readonly on sets read-only mode', async () => { }); test('agents readonly off disables read-only mode', async () => { - const { channelDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); mock.method(channelDb, 'get', () => ({ channel_id: 'ch-1', agent_name: 'TestBot', @@ -476,7 +477,7 @@ test('agents readonly off disables read-only mode', async () => { }); test('agents readonly rejects non-agent channels', async () => { - const { channelDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); mock.method(channelDb, 'get', () => undefined); const interaction = makeInteraction({ @@ -495,7 +496,7 @@ test('agents readonly rejects non-agent channels', async () => { // --- autocomplete --- test('autocomplete filters agents by name', async () => { - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); mock.method(maestro, 'listAgents', async () => [ { id: 'a-1', name: 'AlphaBot', toolType: 'claude', cwd: '/' }, { id: 'a-2', name: 'BetaBot', toolType: 'openai', cwd: '/' }, @@ -519,7 +520,7 @@ test('autocomplete filters agents by name', async () => { }); test('autocomplete returns empty on error', async () => { - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); mock.method(maestro, 'listAgents', async () => { throw new Error('CLI fail'); }); diff --git a/src/__tests__/attachments.test.ts b/src/__tests__/attachments.test.ts index 9d00773..1d3c429 100644 --- a/src/__tests__/attachments.test.ts +++ b/src/__tests__/attachments.test.ts @@ -3,34 +3,21 @@ import assert from 'node:assert/strict'; import { mkdtemp, rm, readFile, stat, mkdir, writeFile } from 'fs/promises'; import path from 'path'; import os from 'os'; -import { Collection } from 'discord.js'; -import type { Attachment } from 'discord.js'; import { downloadAttachments, formatAttachmentRefs, cleanupAgentFiles, MAX_FILE_SIZE, - FILES_DIR, + DEFAULT_FILES_SUBDIR, DownloadedFile, -} from '../utils/attachments'; +} from '../core/attachments'; +import type { IncomingAttachment } from '../core/types'; -// --- Helpers --- - -function makeAttachment( - overrides: Partial & { name: string; url: string; size: number }, -): Attachment { +function makeAttachment(overrides: Partial & { name: string; url: string; size: number }): IncomingAttachment { return { contentType: 'application/octet-stream', ...overrides, - } as unknown as Attachment; -} - -function makeCollection(...items: Attachment[]): Collection { - const col = new Collection(); - for (let i = 0; i < items.length; i++) { - col.set(String(i), items[i]); - } - return col; + }; } function okResponse(body: string | Buffer): Response { @@ -51,8 +38,6 @@ function failResponse(status: number): Response { } as unknown as Response; } -// --- Test setup --- - let tmpDir: string; let originalFetch: typeof globalThis.fetch; @@ -67,19 +52,15 @@ afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }); }); -// --- Tests --- - -test('downloadAttachments creates .maestro/discord-files/ directory', async () => { +test('downloadAttachments creates the default files subdirectory', async () => { globalThis.fetch = () => Promise.resolve(okResponse('content')); const result = await downloadAttachments( - makeCollection( - makeAttachment({ name: 'test.txt', url: 'https://cdn.example.com/test.txt', size: 100 }), - ), + [makeAttachment({ name: 'test.txt', url: 'https://cdn.example.com/test.txt', size: 100 })], tmpDir, ); - const dirStat = await stat(path.join(tmpDir, FILES_DIR)); + const dirStat = await stat(path.join(tmpDir, DEFAULT_FILES_SUBDIR)); assert.ok(dirStat.isDirectory()); assert.equal(result.downloaded.length, 1); assert.deepEqual(result.failed, []); @@ -89,22 +70,18 @@ test('downloadAttachments saves files with UUID-prefixed names', async () => { globalThis.fetch = () => Promise.resolve(okResponse('file content')); const { downloaded, failed } = await downloadAttachments( - makeCollection( - makeAttachment({ name: 'photo.png', url: 'https://cdn.example.com/photo.png', size: 500 }), - ), + [makeAttachment({ name: 'photo.png', url: 'https://cdn.example.com/photo.png', size: 500 })], tmpDir, ); assert.equal(downloaded.length, 1); assert.deepEqual(failed, []); assert.equal(downloaded[0].originalName, 'photo.png'); - assert.ok(downloaded[0].savedPath.includes(FILES_DIR)); + assert.ok(downloaded[0].savedPath.includes(DEFAULT_FILES_SUBDIR)); - // Filename should be {uuid}-photo.png const basename = path.basename(downloaded[0].savedPath); assert.match(basename, /^[0-9a-f-]{36}-photo\.png$/); - // File should contain the expected content const content = await readFile(downloaded[0].savedPath, 'utf-8'); assert.equal(content, 'file content'); }); @@ -115,13 +92,13 @@ test('downloadAttachments skips oversized attachments and reports them as failed }; const { downloaded, failed } = await downloadAttachments( - makeCollection( + [ makeAttachment({ name: 'huge.bin', url: 'https://cdn.example.com/huge.bin', size: MAX_FILE_SIZE + 1, }), - ), + ], tmpDir, ); @@ -138,14 +115,14 @@ test('downloadAttachments skips failed fetches, reports them, and continues', as }; const { downloaded, failed } = await downloadAttachments( - makeCollection( + [ makeAttachment({ name: 'missing.txt', url: 'https://cdn.example.com/missing.txt', size: 100, }), makeAttachment({ name: 'ok.txt', url: 'https://cdn.example.com/ok.txt', size: 100 }), - ), + ], tmpDir, ); @@ -154,8 +131,8 @@ test('downloadAttachments skips failed fetches, reports them, and continues', as assert.deepEqual(failed, ['missing.txt']); }); -test('downloadAttachments returns empty result for empty collection', async () => { - const result = await downloadAttachments(makeCollection(), tmpDir); +test('downloadAttachments returns empty result for empty list', async () => { + const result = await downloadAttachments([], tmpDir); assert.deepEqual(result, { downloaded: [], failed: [] }); }); @@ -175,35 +152,29 @@ test('formatAttachmentRefs returns empty string for empty array', () => { assert.equal(formatAttachmentRefs([]), ''); }); -// --- cleanupAgentFiles tests --- - -test('cleanupAgentFiles removes the discord-files directory', async () => { - // Create the directory structure with a file inside - const filesDir = path.join(tmpDir, FILES_DIR); +test('cleanupAgentFiles removes the default files directory', async () => { + const filesDir = path.join(tmpDir, DEFAULT_FILES_SUBDIR); await mkdir(filesDir, { recursive: true }); await writeFile(path.join(filesDir, 'test.txt'), 'content'); await cleanupAgentFiles(tmpDir); - // Directory should no longer exist - await assert.rejects(() => stat(path.join(tmpDir, FILES_DIR)), { code: 'ENOENT' }); + await assert.rejects(() => stat(path.join(tmpDir, DEFAULT_FILES_SUBDIR)), { code: 'ENOENT' }); }); test('cleanupAgentFiles does not throw if directory does not exist', async () => { - // tmpDir exists but has no .maestro/discord-files/ subdirectory await assert.doesNotReject(() => cleanupAgentFiles(tmpDir)); }); test('downloadAttachments reports all files as failed when mkdir fails', async () => { - // Use a file path as cwd so mkdir(/...) fails deterministically const fileAsCwd = path.join(tmpDir, 'not-a-directory'); await writeFile(fileAsCwd, 'x'); const { downloaded, failed } = await downloadAttachments( - makeCollection( + [ makeAttachment({ name: 'a.txt', url: 'https://cdn.example.com/a.txt', size: 100 }), makeAttachment({ name: 'b.txt', url: 'https://cdn.example.com/b.txt', size: 100 }), - ), + ], fileAsCwd, ); @@ -211,7 +182,7 @@ test('downloadAttachments reports all files as failed when mkdir fails', async ( assert.deepEqual(failed, ['a.txt', 'b.txt']); }); -test('downloadAttachments handles partial failures β€” downloads successes and reports failures', async () => { +test('downloadAttachments handles partial failures', async () => { let callCount = 0; globalThis.fetch = () => { callCount++; @@ -220,11 +191,11 @@ test('downloadAttachments handles partial failures β€” downloads successes and r }; const { downloaded, failed } = await downloadAttachments( - makeCollection( + [ makeAttachment({ name: 'first.txt', url: 'https://cdn.example.com/first.txt', size: 100 }), makeAttachment({ name: 'broken.txt', url: 'https://cdn.example.com/broken.txt', size: 100 }), makeAttachment({ name: 'third.txt', url: 'https://cdn.example.com/third.txt', size: 100 }), - ), + ], tmpDir, ); diff --git a/src/__tests__/auto-run-command.test.ts b/src/__tests__/auto-run-command.test.ts index f20defa..c05fa46 100644 --- a/src/__tests__/auto-run-command.test.ts +++ b/src/__tests__/auto-run-command.test.ts @@ -1,7 +1,7 @@ import test, { afterEach, mock } from 'node:test'; import assert from 'node:assert/strict'; import path from 'path'; -import { execute } from '../commands/auto-run'; +import { execute } from '../providers/discord/commands/auto-run'; afterEach(() => { mock.restoreAll(); @@ -38,7 +38,7 @@ function makeInteraction( } test('auto-run start rejects channels not connected to an agent', async () => { - const { channelDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); mock.method(channelDb, 'get', () => undefined); const i = makeInteraction({ doc: 'plan.md' }); @@ -49,14 +49,14 @@ test('auto-run start rejects channels not connected to an agent', async () => { }); test('auto-run start resolves a bare filename against the agent Auto Run folder', async () => { - const { channelDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); mock.method(channelDb, 'get', () => ({ channel_id: 'ch-1', agent_id: 'agent-1', agent_name: 'TestBot', })); - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); mock.method(maestro, 'showAgent', async () => ({ id: 'agent-1', name: 'TestBot', @@ -76,14 +76,14 @@ test('auto-run start resolves a bare filename against the agent Auto Run folder' }); test('auto-run start resolves a relative subpath against the agent Auto Run folder', async () => { - const { channelDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); mock.method(channelDb, 'get', () => ({ channel_id: 'ch-1', agent_id: 'agent-1', agent_name: 'TestBot', })); - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); mock.method(maestro, 'showAgent', async () => ({ id: 'agent-1', name: 'TestBot', @@ -103,14 +103,14 @@ test('auto-run start resolves a relative subpath against the agent Auto Run fold }); test('auto-run start preserves an absolute path verbatim', async () => { - const { channelDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); mock.method(channelDb, 'get', () => ({ channel_id: 'ch-1', agent_id: 'agent-1', agent_name: 'TestBot', })); - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); // showAgent should not even be called when the path is absolute. const showAgentMock = mock.method(maestro, 'showAgent', async () => { throw new Error('should not be called'); @@ -127,14 +127,14 @@ test('auto-run start preserves an absolute path verbatim', async () => { }); test('auto-run start uses the doc as-is when showAgent fails to resolve a folder', async () => { - const { channelDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); mock.method(channelDb, 'get', () => ({ channel_id: 'ch-1', agent_id: 'agent-1', agent_name: 'TestBot', })); - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); // showAgent throws β€” getAgentFolder should swallow and return null. mock.method(maestro, 'showAgent', async () => { throw new Error('cli unavailable'); @@ -150,14 +150,14 @@ test('auto-run start uses the doc as-is when showAgent fails to resolve a folder }); test('auto-run start uses the doc as-is when autoRunFolderPath is missing', async () => { - const { channelDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); mock.method(channelDb, 'get', () => ({ channel_id: 'ch-1', agent_id: 'agent-1', agent_name: 'TestBot', })); - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); mock.method(maestro, 'showAgent', async () => ({ id: 'agent-1', name: 'TestBot', @@ -176,14 +176,14 @@ test('auto-run start uses the doc as-is when autoRunFolderPath is missing', asyn }); test('auto-run start surfaces errors from startAutoRun', async () => { - const { channelDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); mock.method(channelDb, 'get', () => ({ channel_id: 'ch-1', agent_id: 'agent-1', agent_name: 'TestBot', })); - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); mock.method(maestro, 'showAgent', async () => ({ id: 'agent-1', name: 'TestBot', diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 4f41647..11a107e 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -test('config.required returns values and throws on missing keys', async () => { +test('config loads core + discord values from env and throws on missing keys', async () => { const previousEnv = { ...process.env }; try { @@ -10,34 +10,16 @@ test('config.required returns values and throws on missing keys', async () => { process.env.DISCORD_GUILD_ID = 'guild-789'; process.env.DISCORD_ALLOWED_USER_IDS = ' 111,222 ,, 333 '; - const imported = (await import('../config')) as { - default?: unknown; - required?: (key: string) => string; - config?: { - token: string; - clientId: string; - guildId: string; - allowedUserIds: string[]; - }; - }; + const core = await import('../core/config'); + const discord = await import('../providers/discord/config'); - const configModule = (imported.default ?? imported) as { - required: (key: string) => string; - config: { - token: string; - clientId: string; - guildId: string; - allowedUserIds: string[]; - }; - }; + assert.equal(core.required('DISCORD_BOT_TOKEN'), 'token-123'); + assert.equal(discord.discordConfig.token, 'token-123'); + assert.equal(discord.discordConfig.clientId, 'client-456'); + assert.equal(discord.discordConfig.guildId, 'guild-789'); + assert.deepEqual(discord.discordConfig.allowedUserIds, ['111', '222', '333']); - assert.equal(configModule.required('DISCORD_BOT_TOKEN'), 'token-123'); - assert.equal(configModule.config.token, 'token-123'); - assert.equal(configModule.config.clientId, 'client-456'); - assert.equal(configModule.config.guildId, 'guild-789'); - assert.deepEqual(configModule.config.allowedUserIds, ['111', '222', '333']); - - assert.throws(() => configModule.required('MISSING_ENV'), /Missing required env var/); + assert.throws(() => core.required('MISSING_ENV'), /Missing required env var/); } finally { for (const key of Object.keys(process.env)) { if (!(key in previousEnv)) delete process.env[key]; diff --git a/src/__tests__/db-migrations.test.ts b/src/__tests__/db-migrations.test.ts index dad64b9..9eb89d8 100644 --- a/src/__tests__/db-migrations.test.ts +++ b/src/__tests__/db-migrations.test.ts @@ -1,13 +1,13 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import Database from 'better-sqlite3'; -import { ensureOwnerUserIdColumn } from '../db/migrations'; +import { ensureOwnerUserIdColumn, runMigrations } from '../core/db/migrations'; test('ensureOwnerUserIdColumn adds owner_user_id and is safe to rerun', () => { const database = new Database(':memory:'); database.exec(` - CREATE TABLE agent_threads ( + CREATE TABLE discord_agent_threads ( thread_id TEXT PRIMARY KEY, channel_id TEXT NOT NULL, agent_id TEXT NOT NULL, @@ -19,9 +19,94 @@ test('ensureOwnerUserIdColumn adds owner_user_id and is safe to rerun', () => { ensureOwnerUserIdColumn(database); ensureOwnerUserIdColumn(database); - const columns = database.prepare('PRAGMA table_info(agent_threads)').all() as Array<{ + const columns = database + .prepare('PRAGMA table_info(discord_agent_threads)') + .all() as Array<{ name: string }>; + + assert.ok(columns.some((column) => column.name === 'owner_user_id')); +}); + +test('runMigrations upgrades a legacy schema: adds provider column, renames threads table', () => { + const database = new Database(':memory:'); + + // Legacy schema (pre-multi-provider): channel_id is the standalone PK, + // agent_threads has the old name. + database.exec(` + CREATE TABLE agent_channels ( + channel_id TEXT PRIMARY KEY, + guild_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + agent_name TEXT NOT NULL, + session_id TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) + ) + `); + database.exec(` + CREATE TABLE agent_threads ( + thread_id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + session_id TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) + ) + `); + database + .prepare('INSERT INTO agent_channels (channel_id, guild_id, agent_id, agent_name) VALUES (?, ?, ?, ?)') + .run('ch-legacy', 'g1', 'a1', 'Legacy Agent'); + database + .prepare('INSERT INTO agent_threads (thread_id, channel_id, agent_id) VALUES (?, ?, ?)') + .run('th-legacy', 'ch-legacy', 'a1'); + + runMigrations(database); + + // agent_channels now has the provider column with default 'discord'. + const cols = database.prepare("PRAGMA table_info('agent_channels')").all() as Array<{ name: string; }>; + assert.ok(cols.some((c) => c.name === 'provider')); + assert.ok(cols.some((c) => c.name === 'read_only')); - assert.ok(columns.some((column) => column.name === 'owner_user_id')); + const row = database + .prepare('SELECT provider, channel_id, agent_id FROM agent_channels WHERE channel_id = ?') + .get('ch-legacy') as { provider: string; channel_id: string; agent_id: string }; + assert.equal(row.provider, 'discord'); + assert.equal(row.agent_id, 'a1'); + + // agent_threads has been renamed to discord_agent_threads with data preserved. + const threadRows = database + .prepare('SELECT thread_id FROM discord_agent_threads') + .all() as Array<{ thread_id: string }>; + assert.equal(threadRows.length, 1); + assert.equal(threadRows[0].thread_id, 'th-legacy'); + + // Old table is gone. + const oldTable = database + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='agent_threads'") + .get(); + assert.equal(oldTable, undefined); +}); + +test('runMigrations is idempotent on the new schema', () => { + const database = new Database(':memory:'); + database.exec(` + CREATE TABLE agent_channels ( + provider TEXT NOT NULL DEFAULT 'discord', + channel_id TEXT NOT NULL, + guild_id TEXT, + agent_id TEXT NOT NULL, + agent_name TEXT NOT NULL, + session_id TEXT, + read_only INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (provider, channel_id) + ) + `); + + runMigrations(database); + runMigrations(database); + + const cols = database.prepare("PRAGMA table_info('agent_channels')").all() as Array<{ + name: string; + }>; + assert.ok(cols.some((c) => c.name === 'provider')); }); diff --git a/src/__tests__/db.test.ts b/src/__tests__/db.test.ts index ef90338..74eb8be 100644 --- a/src/__tests__/db.test.ts +++ b/src/__tests__/db.test.ts @@ -1,6 +1,7 @@ import test, { afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import { channelDb, threadDb } from '../db'; +import { channelDb } from '../providers/discord/channelsDb'; +import { threadDb } from '../providers/discord/threadsDb'; // Use unique IDs per test run to avoid cross-test contamination let testId = 0; diff --git a/src/__tests__/embed.test.ts b/src/__tests__/embed.test.ts index 4bc1eb5..f3d3240 100644 --- a/src/__tests__/embed.test.ts +++ b/src/__tests__/embed.test.ts @@ -8,7 +8,7 @@ import { clampFieldValue, clampText, clampTitle, -} from '../utils/embed'; +} from '../providers/discord/embed'; test('clampText returns input unchanged when within limit', () => { assert.equal(clampText('hello', 10), 'hello'); diff --git a/src/__tests__/getAgentCwd.test.ts b/src/__tests__/getAgentCwd.test.ts index 5e9d3e5..3379a06 100644 --- a/src/__tests__/getAgentCwd.test.ts +++ b/src/__tests__/getAgentCwd.test.ts @@ -1,7 +1,7 @@ import test, { afterEach, mock } from 'node:test'; import assert from 'node:assert/strict'; -import { maestro, resetAgentCwdCache } from '../services/maestro'; -import type { MaestroAgent } from '../services/maestro'; +import { maestro, resetAgentCwdCache } from '../core/maestro'; +import type { MaestroAgent } from '../core/maestro'; const fakeAgents: MaestroAgent[] = [ { id: 'agent-1', name: 'Agent One', toolType: 'claude', cwd: '/home/user/project-a' }, diff --git a/src/__tests__/gist-command.test.ts b/src/__tests__/gist-command.test.ts index ea0ebdc..734740a 100644 --- a/src/__tests__/gist-command.test.ts +++ b/src/__tests__/gist-command.test.ts @@ -1,6 +1,6 @@ import test, { afterEach, mock } from 'node:test'; import assert from 'node:assert/strict'; -import { execute } from '../commands/gist'; +import { execute } from '../providers/discord/commands/gist'; afterEach(() => { mock.restoreAll(); @@ -33,7 +33,7 @@ function makeInteraction( } test('gist rejects channels not connected to an agent', async () => { - const { channelDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); mock.method(channelDb, 'get', () => undefined); const i = makeInteraction(); @@ -44,14 +44,14 @@ test('gist rejects channels not connected to an agent', async () => { }); test('gist publishes and renders an embed with the gist url', async () => { - const { channelDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); mock.method(channelDb, 'get', () => ({ channel_id: 'ch-1', agent_id: 'agent-1', agent_name: 'TestBot', })); - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); mock.method(maestro, 'createGist', async () => ({ url: 'https://gist.example/abc', id: 'abc', @@ -69,14 +69,14 @@ test('gist publishes and renders an embed with the gist url', async () => { }); test('gist surfaces a friendly error when createGist throws an Error', async () => { - const { channelDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); mock.method(channelDb, 'get', () => ({ channel_id: 'ch-1', agent_id: 'agent-1', agent_name: 'TestBot', })); - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); mock.method(maestro, 'createGist', async () => { throw new Error('gh not authenticated'); }); @@ -91,14 +91,14 @@ test('gist surfaces a friendly error when createGist throws an Error', async () }); test('gist tolerates non-Error throws (string)', async () => { - const { channelDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); mock.method(channelDb, 'get', () => ({ channel_id: 'ch-1', agent_id: 'agent-1', agent_name: 'TestBot', })); - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); // Reject with a non-Error value β€” must not blow up the catch handler. mock.method(maestro, 'createGist', async () => { throw 'plain string failure'; @@ -114,14 +114,14 @@ test('gist tolerates non-Error throws (string)', async () => { }); test('gist tolerates non-Error throws (object without message)', async () => { - const { channelDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); mock.method(channelDb, 'get', () => ({ channel_id: 'ch-1', agent_id: 'agent-1', agent_name: 'TestBot', })); - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); mock.method(maestro, 'createGist', async () => { throw { code: 42 }; }); @@ -135,14 +135,14 @@ test('gist tolerates non-Error throws (object without message)', async () => { }); test('gist truncates very long error messages to 1500 chars', async () => { - const { channelDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); mock.method(channelDb, 'get', () => ({ channel_id: 'ch-1', agent_id: 'agent-1', agent_name: 'TestBot', })); - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); const huge = 'x'.repeat(5000); mock.method(maestro, 'createGist', async () => { throw new Error(huge); diff --git a/src/__tests__/messageCreate.test.ts b/src/__tests__/messageCreate.test.ts index 2f755ff..0651014 100644 --- a/src/__tests__/messageCreate.test.ts +++ b/src/__tests__/messageCreate.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createMessageCreateHandler } from '../handlers/messageCreate'; +import { createMessageCreateHandler } from '../providers/discord/messageCreate'; function makeAttachments(items: any[] = []) { const filter = (predicate: (a: any) => boolean) => makeAttachments(items.filter(predicate)); @@ -203,6 +203,9 @@ test('handleMessageCreate creates and registers a thread for bot mentions in reg return { id: 'msg-forwarded', content: text, + author: { id: 'user-1', username: 'test-user' }, + member: { displayName: 'Test User' }, + channel: { id: 'thread-new-1', isThread: () => true }, attachments: { size: 0, values: () => [] }, }; }, @@ -285,6 +288,9 @@ test('handleMessageCreate forwards attachments as AttachmentPayload objects in m return { id: 'msg-att-forwarded', content: typeof msg === 'string' ? msg : (msg.content ?? ''), + author: { id: 'user-1', username: 'test-user' }, + member: { displayName: 'Test User' }, + channel: { id: 'thread-att-1', isThread: () => true }, // Simulate discord.js: when sent with AttachmentPayload, the // returned message should have real attachments attachments: { @@ -306,9 +312,9 @@ test('handleMessageCreate forwards attachments as AttachmentPayload objects in m ); assert.equal(enqueued, 1); - // The enqueued message should have real attachments (not size 0) + // The enqueued message should have real attachments (not empty) assert.ok(enqueuedMessage); - assert.equal(enqueuedMessage.attachments.size, 1); + assert.equal(enqueuedMessage.attachments.length, 1); }); test('handleMessageCreate ignores non-thread channel messages without bot mention', async () => { @@ -373,7 +379,7 @@ test('handleMessageCreate transcribes voice messages and enqueues transcription assert.equal(enqueueCalls.length, 1); assert.equal((enqueueCalls[0][1] as any).contentOverride, 'hello from voice'); - assert.equal((enqueueCalls[0][1] as any).attachmentsOverride.size, 0); + assert.equal((enqueueCalls[0][1] as any).attachmentsOverride.length, 0); assert.ok(reactions.includes('🎧'), 'should have 🎧 reaction'); assert.ok(replies.some((r) => r.includes('🎧')), 'should have 🎧 in transcription reply'); assert.deepEqual( @@ -405,9 +411,16 @@ test('handleMessageCreate preserves non-voice attachments when message mixes voi assert.equal(enqueueCalls.length, 1); const options = enqueueCalls[0][1] as any; - const overrideValues = [...options.attachmentsOverride.values()]; - assert.equal(options.attachmentsOverride.size, 1, 'voice attachment should be filtered out'); - assert.equal(overrideValues[0], image, 'non-voice attachment should be preserved for the agent'); + assert.equal( + options.attachmentsOverride.length, + 1, + 'voice attachment should be filtered out', + ); + assert.equal( + options.attachmentsOverride[0].name, + image.name, + 'non-voice attachment should be preserved for the agent', + ); assert.equal( options.contentOverride, 'see attached\n\nhello from voice', diff --git a/src/__tests__/mockProvider.test.ts b/src/__tests__/mockProvider.test.ts new file mode 100644 index 0000000..3a2341d --- /dev/null +++ b/src/__tests__/mockProvider.test.ts @@ -0,0 +1,110 @@ +import test, { mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { createQueue } from '../core/queue'; +import type { + BridgeProvider, + ConversationRecord, + IncomingMessage, + KernelContext, +} from '../core/types'; + +/** + * Smoke test: verifies that a brand-new BridgeProvider implementation can drive + * the kernel end-to-end (start β†’ resolve conversation β†’ send β†’ react β†’ typing) + * without touching any Discord-specific code. + */ +test('a minimal MockProvider satisfies BridgeProvider and works with the kernel queue', async () => { + const sent: { channelId: string; text: string; mention?: boolean }[] = []; + const reacted: string[] = []; + let typingPings = 0; + let removedReactions = 0; + + const persistSession = mock.fn(); + const conv: ConversationRecord = { + agentId: 'agent-1', + sessionId: null, + readOnly: false, + persistSession, + }; + + const ctxRecord: { ctx: KernelContext | null } = { ctx: null }; + + const mockProvider: BridgeProvider = { + name: 'mock', + async start(ctx) { + ctxRecord.ctx = ctx; + }, + async stop() {}, + isReady: () => true, + resolveConversation: () => conv, + send: async (target, msg) => { + sent.push({ channelId: target.channelId, text: msg.text, mention: msg.mention }); + }, + findOrCreateAgentChannel: async (agentId) => ({ + channelId: 'mock-ch-1', + agentId, + agentName: 'Mock Agent', + }), + react: async (_target, emoji) => { + reacted.push(emoji); + return { + remove: async () => { + removedReactions += 1; + }, + }; + }, + sendTyping: async () => { + typingPings += 1; + }, + }; + + const queue = createQueue({ + maestro: { + getAgentCwd: async () => null, + send: async () => ({ + success: true, + response: 'agent reply', + sessionId: 'session-99', + usage: { + inputTokens: 1, + outputTokens: 2, + totalCostUsd: 0.0001, + contextUsagePercent: 1, + }, + }), + }, + getProvider: (name) => (name === 'mock' ? mockProvider : undefined), + splitMessage: (text) => [text], + logger: { error: () => {} }, + }); + + await mockProvider.start({ enqueue: queue.enqueue, logger: { error: () => {} } }); + + const message: IncomingMessage = { + provider: 'mock', + messageId: 'm1', + channelId: 'mock-ch-1', + authorId: 'u1', + authorName: 'User', + content: 'hello', + attachments: [], + isThread: false, + }; + + queue.enqueue(message); + await new Promise((r) => setTimeout(r, 50)); + + // Provider received the agent reply and the cost line + assert.ok(sent.some((m) => m.text === 'agent reply')); + assert.ok(sent.some((m) => m.text.includes('tokens'))); + + // Reaction lifecycle ran + assert.deepEqual(reacted, ['⏳']); + assert.equal(removedReactions, 1); + + // At least one typing ping happened before the reply + assert.ok(typingPings >= 1); + + // Session id was persisted from the maestro response + assert.equal(persistSession.mock.callCount(), 1); +}); diff --git a/src/__tests__/playbook-command.test.ts b/src/__tests__/playbook-command.test.ts index 401d2eb..49cfa13 100644 --- a/src/__tests__/playbook-command.test.ts +++ b/src/__tests__/playbook-command.test.ts @@ -1,11 +1,11 @@ import test, { afterEach, mock } from 'node:test'; import assert from 'node:assert/strict'; -import { execute } from '../commands/playbook'; +import { execute } from '../providers/discord/commands/playbook'; import { EMBED_DESCRIPTION_MAX, EMBED_FIELD_VALUE_MAX, EMBED_TITLE_MAX, -} from '../utils/embed'; +} from '../providers/discord/embed'; afterEach(() => { mock.restoreAll(); @@ -34,7 +34,7 @@ function makeInteraction(sub: string, options: Record = { } test('playbook list renders an embed with playbooks', async () => { - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); mock.method(maestro, 'listPlaybooks', async () => [ { id: 'pb-1', @@ -56,7 +56,7 @@ test('playbook list renders an embed with playbooks', async () => { }); test('playbook list shows a friendly message when no playbooks exist', async () => { - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); mock.method(maestro, 'listPlaybooks', async () => []); const i = makeInteraction('list'); @@ -68,7 +68,7 @@ test('playbook list shows a friendly message when no playbooks exist', async () }); test('playbook show clamps oversize description and document field', async () => { - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); mock.method(maestro, 'showPlaybook', async () => ({ id: 'pb-1', name: 'Big Playbook', @@ -99,7 +99,7 @@ test('playbook show clamps oversize description and document field', async () => }); test('playbook show clamps oversize title and agent name', async () => { - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); const longName = 'P'.repeat(EMBED_TITLE_MAX + 500); const longAgent = 'A'.repeat(EMBED_FIELD_VALUE_MAX + 500); mock.method(maestro, 'showPlaybook', async () => ({ @@ -134,7 +134,7 @@ test('playbook show clamps oversize title and agent name', async () => { }); test('playbook show surfaces a friendly error when load fails', async () => { - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); mock.method(maestro, 'showPlaybook', async () => { throw new Error('not found'); }); diff --git a/src/__tests__/queue.test.ts b/src/__tests__/queue.test.ts index efb03af..cb3eb4a 100644 --- a/src/__tests__/queue.test.ts +++ b/src/__tests__/queue.test.ts @@ -1,22 +1,25 @@ import test, { mock } from 'node:test'; import assert from 'node:assert/strict'; -import { createQueue, QueueDeps } from '../services/queueFactory'; - -// --- Helpers --- - -function makeMessage(overrides: Record = {}) { +import { createQueue, type QueueDeps } from '../core/queue'; +import type { + BridgeProvider, + ConversationRecord, + IncomingAttachment, + IncomingMessage, +} from '../core/types'; + +function makeMessage(overrides: Partial = {}): IncomingMessage { return { + provider: 'mock', + messageId: 'msg-1', + channelId: 'thread-1', + authorId: 'user-1', + authorName: 'User One', content: 'hello', - attachments: { size: 0, values: () => [] }, - channel: { - id: 'thread-1', - isThread: () => true, - send: mock.fn(async () => {}), - sendTyping: mock.fn(async () => {}), - }, - react: mock.fn(async () => ({ remove: mock.fn(async () => {}) })), + attachments: [], + isThread: true, ...overrides, - } as any; + }; } function defaultSendResult(extra: Record = {}) { @@ -29,7 +32,17 @@ function defaultSendResult(extra: Record = {}) { }; } -function createMockDeps(): QueueDeps & { _mocks: Record> } { +interface MockProviderInstance extends BridgeProvider { + sentTexts: string[]; +} + +interface MockSetup { + deps: QueueDeps & { _mocks: Record> }; + provider: MockProviderInstance; + conv: ConversationRecord; +} + +function createMocks(overrides: Partial = {}): MockSetup { const mockGetAgentCwd = mock.fn(async () => '/home/agent' as string | null); const mockSend = mock.fn(async () => defaultSendResult()); const mockDownload = mock.fn(async () => ({ @@ -38,28 +51,39 @@ function createMockDeps(): QueueDeps & { _mocks: Record ''); const mockLoggerError = mock.fn(); - const mockChannelGet = mock.fn( - () => - ({ - channel_id: 'channel-1', - agent_id: 'agent-1', - session_id: 'session-1', - }) as any, - ); - const mockThreadGet = mock.fn( - () => - ({ - thread_id: 'thread-1', - channel_id: 'channel-1', - agent_id: 'agent-1', - session_id: 'session-1', - }) as any, - ); + const mockPersistSession = mock.fn(); - return { + const conv: ConversationRecord = { + agentId: 'agent-1', + sessionId: 'session-1', + readOnly: false, + persistSession: mockPersistSession as unknown as (s: string) => void, + ...overrides, + }; + + const sentTexts: string[] = []; + const provider: MockProviderInstance = { + name: 'mock', + sentTexts, + async start() {}, + async stop() {}, + isReady: () => true, + resolveConversation: () => conv, + send: async (_target, msg) => { + sentTexts.push(msg.text); + }, + findOrCreateAgentChannel: async () => ({ + channelId: 'channel-1', + agentId: conv.agentId, + agentName: 'Agent', + }), + react: mock.fn(async () => ({ remove: async () => {} })) as unknown as BridgeProvider['react'], + sendTyping: async () => {}, + }; + + const deps: QueueDeps & { _mocks: Record> } = { maestro: { getAgentCwd: mockGetAgentCwd as any, send: mockSend as any }, - channelDb: { get: mockChannelGet as any, updateSession: mock.fn() }, - threadDb: { get: mockThreadGet as any, updateSession: mock.fn() }, + getProvider: (name) => (name === 'mock' ? provider : undefined), splitMessage: (text: string) => [text], downloadAttachments: mockDownload as any, formatAttachmentRefs: mockFormat as any, @@ -69,17 +93,18 @@ function createMockDeps(): QueueDeps & { _mocks: Record new Promise((r) => setTimeout(r, 50)); -// --- Tests --- - test('queue calls downloadAttachments when message has attachments', async () => { - const deps = createMockDeps(); + const { deps, provider } = createMocks(); const attachmentData = { downloaded: [ { originalName: 'file.txt', savedPath: '/home/agent/.maestro/discord-files/123-file.txt' }, @@ -92,51 +117,44 @@ test('queue calls downloadAttachments when message has attachments', async () => ); const { enqueue } = createQueue(deps); - const msg = makeMessage({ - content: 'check this file', - attachments: { - size: 1, - values: () => [{ url: 'https://cdn.example.com/file.txt', name: 'file.txt', size: 100 }], - }, - }); - - enqueue(msg); + const attachments: IncomingAttachment[] = [ + { url: 'https://cdn.example.com/file.txt', name: 'file.txt', size: 100 }, + ]; + enqueue(makeMessage({ content: 'check this file', attachments })); await settle(); - // downloadAttachments should have been called assert.equal(deps._mocks.download.mock.callCount(), 1); assert.equal(deps._mocks.getAgentCwd.mock.callCount(), 1); assert.equal(deps._mocks.getAgentCwd.mock.calls[0].arguments[0], 'agent-1'); - // formatAttachmentRefs should have been called with the downloaded files assert.equal(deps._mocks.format.mock.callCount(), 1); - // maestro.send should receive the combined message assert.equal(deps._mocks.send.mock.callCount(), 1); const sentMessage = deps._mocks.send.mock.calls[0].arguments[1]; assert.equal( sentMessage, 'check this file\n\n[Attached: /home/agent/.maestro/discord-files/123-file.txt]', ); + + // Provider should have been used for the agent reply + the cost line + assert.ok(provider.sentTexts.includes('Agent response')); }); test('queue does not call downloadAttachments when message has no attachments', async () => { - const deps = createMockDeps(); + const { deps } = createMocks(); const { enqueue } = createQueue(deps); - enqueue(makeMessage({ content: 'just text', attachments: { size: 0, values: () => [] } })); + enqueue(makeMessage({ content: 'just text' })); await settle(); assert.equal(deps._mocks.download.mock.callCount(), 0); assert.equal(deps._mocks.getAgentCwd.mock.callCount(), 0); - - // maestro.send should receive only the text content assert.equal(deps._mocks.send.mock.callCount(), 1); assert.equal(deps._mocks.send.mock.calls[0].arguments[1], 'just text'); }); test('queue sends only attachment refs when message content is empty', async () => { - const deps = createMockDeps(); + const { deps } = createMocks(); deps._mocks.download.mock.mockImplementation(async () => ({ downloaded: [ { originalName: 'img.png', savedPath: '/home/agent/.maestro/discord-files/456-img.png' }, @@ -151,60 +169,45 @@ test('queue sends only attachment refs when message content is empty', async () enqueue( makeMessage({ content: '', - attachments: { - size: 1, - values: () => [{ url: 'https://cdn.example.com/img.png', name: 'img.png', size: 200 }], - }, + attachments: [{ url: 'https://cdn.example.com/img.png', name: 'img.png', size: 200 }], }), ); await settle(); assert.equal(deps._mocks.send.mock.callCount(), 1); - const sentMessage = deps._mocks.send.mock.calls[0].arguments[1]; - assert.equal(sentMessage, '[Attached: /home/agent/.maestro/discord-files/456-img.png]'); + assert.equal( + deps._mocks.send.mock.calls[0].arguments[1], + '[Attached: /home/agent/.maestro/discord-files/456-img.png]', + ); }); test('queue handles attachment download failure gracefully', async () => { - const deps = createMockDeps(); + const { deps, provider } = createMocks(); deps._mocks.download.mock.mockImplementation(async () => { throw new Error('Network timeout'); }); const { enqueue } = createQueue(deps); - const msg = makeMessage({ - content: 'check this file', - attachments: { - size: 1, - values: () => [{ url: 'https://cdn.example.com/file.txt', name: 'file.txt', size: 100 }], - }, - }); - - enqueue(msg); + enqueue( + makeMessage({ + content: 'check this file', + attachments: [{ url: 'https://cdn.example.com/file.txt', name: 'file.txt', size: 100 }], + }), + ); await settle(); - // Should log the error - assert.equal((deps.logger.error as unknown as ReturnType).mock.callCount(), 1); - const logArgs = (deps.logger.error as unknown as ReturnType).mock.calls[0] - .arguments; + assert.equal(deps._mocks.loggerError.mock.callCount(), 1); + const logArgs = deps._mocks.loggerError.mock.calls[0].arguments; assert.equal(logArgs[0], 'queue:attachment-download'); assert.ok((logArgs[1] as string).includes('Network timeout')); - // Should warn the user - const sendCalls = msg.channel.send.mock.calls; - const warningCall = sendCalls.find( - (c: { arguments: unknown[] }) => - typeof c.arguments[0] === 'string' && - c.arguments[0].includes('Failed to download attachments'), - ); - assert.ok(warningCall, 'Expected a warning about failed downloads'); - - // Should still send the message text to the agent (without attachment refs) + assert.ok(provider.sentTexts.some((t) => t.includes('Failed to download attachments'))); assert.equal(deps._mocks.send.mock.callCount(), 1); assert.equal(deps._mocks.send.mock.calls[0].arguments[1], 'check this file'); }); test('queue shows specific file names when some downloads fail', async () => { - const deps = createMockDeps(); + const { deps, provider } = createMocks(); deps._mocks.download.mock.mockImplementation(async () => ({ downloaded: [ { originalName: 'ok.txt', savedPath: '/home/agent/.maestro/discord-files/ok.txt' }, @@ -216,68 +219,50 @@ test('queue shows specific file names when some downloads fail', async () => { ); const { enqueue } = createQueue(deps); - const msg = makeMessage({ - content: 'files here', - attachments: { - size: 3, - values: () => [ + enqueue( + makeMessage({ + content: 'files here', + attachments: [ { url: 'u1', name: 'ok.txt', size: 100 }, { url: 'u2', name: 'broken.png', size: 100 }, { url: 'u3', name: 'huge.bin', size: 100 }, ], - }, - }); - - enqueue(msg); + }), + ); await settle(); - // Should warn about the specific failed files - const sendCalls = msg.channel.send.mock.calls; - const warningCall = sendCalls.find( - (c: { arguments: unknown[] }) => - typeof c.arguments[0] === 'string' && - c.arguments[0].includes('broken.png') && - c.arguments[0].includes('huge.bin'), + assert.ok( + provider.sentTexts.some((t) => t.includes('broken.png') && t.includes('huge.bin')), + 'expected a warning naming the failed files', ); - assert.ok(warningCall, 'Expected a warning naming the failed files'); - // Should still send the message with the successful attachment ref assert.equal(deps._mocks.send.mock.callCount(), 1); const sentMessage = deps._mocks.send.mock.calls[0].arguments[1]; assert.ok((sentMessage as string).includes('[Attached:')); }); test('queue warns when agent cwd cannot be resolved for attachments', async () => { - const deps = createMockDeps(); + const { deps, provider } = createMocks(); deps._mocks.getAgentCwd.mock.mockImplementation(async () => null); const { enqueue } = createQueue(deps); - const msg = makeMessage({ - content: 'here is a file', - attachments: { - size: 1, - values: () => [{ url: 'https://cdn.example.com/file.txt', name: 'file.txt', size: 100 }], - }, - }); - - enqueue(msg); + enqueue( + makeMessage({ + content: 'here is a file', + attachments: [{ url: 'https://cdn.example.com/file.txt', name: 'file.txt', size: 100 }], + }), + ); await settle(); - // downloadAttachments should NOT be called if cwd is null assert.equal(deps._mocks.download.mock.callCount(), 0); - - // Channel should receive a warning message - const sendCalls = msg.channel.send.mock.calls; - const warningCall = sendCalls.find( - (c: { arguments: unknown[] }) => - typeof c.arguments[0] === 'string' && - c.arguments[0].includes('Could not resolve agent working directory'), + assert.ok( + provider.sentTexts.some((t) => t.includes('Could not resolve agent working directory')), + 'expected a warning about unresolved agent cwd', ); - assert.ok(warningCall, 'Expected a warning about unresolved agent cwd'); }); test('queue uses contentOverride when provided', async () => { - const deps = createMockDeps(); + const { deps } = createMocks(); const { enqueue } = createQueue(deps); enqueue(makeMessage({ content: 'original text' }), { contentOverride: 'transcribed text' }); await settle(); @@ -287,17 +272,14 @@ test('queue uses contentOverride when provided', async () => { }); test('queue skips attachment downloads when attachmentsOverride is empty', async () => { - const deps = createMockDeps(); + const { deps } = createMocks(); const { enqueue } = createQueue(deps); enqueue( makeMessage({ content: 'voice text', - attachments: { - size: 1, - values: () => [{ url: 'https://cdn.example.com/voice.ogg', name: 'voice.ogg', size: 100 }], - }, + attachments: [{ url: 'https://cdn.example.com/voice.ogg', name: 'voice.ogg', size: 100 }], }), - { attachmentsOverride: { size: 0, values: () => [] } as any }, + { attachmentsOverride: [] }, ); await settle(); @@ -306,8 +288,8 @@ test('queue skips attachment downloads when attachmentsOverride is empty', async assert.equal(deps._mocks.send.mock.calls[0].arguments[1], 'voice text'); }); -test('queue downloads only the attachmentsOverride collection (drops voice in mixed messages)', async () => { - const deps = createMockDeps(); +test('queue downloads only the attachmentsOverride list (drops voice in mixed messages)', async () => { + const { deps } = createMocks(); const downloadedFile = { originalName: 'photo.png', savedPath: '/home/agent/.maestro/discord-files/photo.png', @@ -318,25 +300,24 @@ test('queue downloads only the attachmentsOverride collection (drops voice in mi })); deps._mocks.format.mock.mockImplementation(() => `[Attached: ${downloadedFile.savedPath}]`); - const image = { url: 'https://cdn.example.com/photo.png', name: 'photo.png', size: 100 }; - const overrideAttachments = { size: 1, values: () => [image] } as any; + const image: IncomingAttachment = { + url: 'https://cdn.example.com/photo.png', + name: 'photo.png', + size: 100, + }; + const overrideAttachments: IncomingAttachment[] = [image]; const { enqueue } = createQueue(deps); enqueue( makeMessage({ content: 'see photo', - // The original message still carries both the voice file and the image… - attachments: { - size: 2, - values: () => [ - { url: 'https://cdn.example.com/voice.ogg', name: 'voice.ogg', size: 100 }, - image, - ], - }, + attachments: [ + { url: 'https://cdn.example.com/voice.ogg', name: 'voice.ogg', size: 100 }, + image, + ], }), { contentOverride: 'see photo\n\nhello from voice', - // …but only the non-voice subset is forwarded for download. attachmentsOverride: overrideAttachments, }, ); @@ -346,7 +327,7 @@ test('queue downloads only the attachmentsOverride collection (drops voice in mi assert.equal( deps._mocks.download.mock.calls[0].arguments[0], overrideAttachments, - 'queue should pass the override (image only), not the original mixed attachments, to downloadAttachments', + 'queue should pass the override (image only), not the original mixed attachments', ); assert.equal(deps._mocks.send.mock.callCount(), 1); assert.equal( @@ -354,3 +335,37 @@ test('queue downloads only the attachmentsOverride collection (drops voice in mi `see photo\n\nhello from voice\n\n[Attached: ${downloadedFile.savedPath}]`, ); }); + +test('queue persists session id from the first response', async () => { + const { deps } = createMocks({ sessionId: null }); + const { enqueue } = createQueue(deps); + enqueue(makeMessage()); + await settle(); + + assert.equal(deps._mocks.persistSession.mock.callCount(), 1); + assert.equal(deps._mocks.persistSession.mock.calls[0].arguments[0], 'session-1'); +}); + +test('queue drops messages whose conversation cannot be resolved', async () => { + const { deps, provider } = createMocks(); + provider.resolveConversation = () => null; + + const { enqueue } = createQueue(deps); + enqueue(makeMessage()); + await settle(); + + assert.equal(deps._mocks.send.mock.callCount(), 0); +}); + +test('queue logs and skips when the named provider is not registered', async () => { + const { deps } = createMocks(); + deps.getProvider = () => undefined; + + const { enqueue } = createQueue(deps); + enqueue(makeMessage({ provider: 'ghost' })); + await settle(); + + assert.equal(deps._mocks.send.mock.callCount(), 0); + assert.equal(deps._mocks.loggerError.mock.callCount(), 1); + assert.equal(deps._mocks.loggerError.mock.calls[0].arguments[0], 'queue:no-provider'); +}); diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index 97af96c..15dd33a 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -1,56 +1,59 @@ import test, { before } from 'node:test'; import assert from 'node:assert/strict'; import http from 'http'; - -type ServerDeps = import('../server').ServerDeps; +import type { ApiDeps } from '../core/api'; +import type { BridgeProvider } from '../core/types'; const mod: { - createServerHandler?: typeof import('../server').createServerHandler; - parseBody?: typeof import('../server').parseBody; + createServerHandler?: typeof import('../core/api').createServerHandler; + parseBody?: typeof import('../core/api').parseBody; } = {}; before(async () => { process.env.DISCORD_BOT_TOKEN = 'test-token'; process.env.DISCORD_CLIENT_ID = 'test-client'; process.env.DISCORD_GUILD_ID = 'test-guild'; - const imported = await import('../server'); + const imported = await import('../core/api'); mod.createServerHandler = imported.createServerHandler; mod.parseBody = imported.parseBody; }); -function makeClient(overrides: Record = {}) { +interface MockProviderOpts { + ready?: boolean; + agentName?: string; + channelId?: string; + /** if set, throw when findOrCreateAgentChannel is called */ + findThrows?: Error; + /** capture sent messages */ + sentMessages?: string[]; +} + +function makeProvider(name: string, opts: MockProviderOpts = {}): BridgeProvider { + const sent = opts.sentMessages ?? []; return { - isReady: () => true, - channels: { - fetch: async (id: string) => ({ - id, - isSendable: () => true, - send: async () => ({}), - members: { filter: () => ({ size: 0, map: () => [] }) }, - }), + name, + isReady: () => opts.ready !== false, + async start() {}, + async stop() {}, + resolveConversation: () => null, + send: async (_target, msg) => { + sent.push(msg.mention ? `<@MENTION> ${msg.text}` : msg.text); }, - guilds: { fetch: async () => ({}) }, - ...overrides, - } as any; + findOrCreateAgentChannel: async (agentId) => { + if (opts.findThrows) throw opts.findThrows; + return { + channelId: opts.channelId ?? 'ch-1', + agentId, + agentName: opts.agentName ?? 'Test', + }; + }, + }; } -function makeDeps(overrides: Partial = {}): ServerDeps { +function makeDeps(overrides: Partial = {}): ApiDeps { return { - channelDb: { - getByAgentId: () => ({ - channel_id: 'ch-1', - guild_id: 'g-1', - agent_id: 'a-1', - agent_name: 'Test', - session_id: null, - read_only: 0, - created_at: 0, - }), - register: () => undefined, - }, - maestro: { listAgents: async () => [] }, + providers: new Map([['discord', makeProvider('discord')]]), splitMessage: (s: string) => [s], - config: { guildId: 'g-1', apiPort: 0, mentionUserId: '' }, logger: { error: async () => undefined }, ...overrides, }; @@ -93,8 +96,8 @@ function request( }); } -function startTestServer(client: any, deps: ServerDeps): Promise { - const handler = mod.createServerHandler!(client, deps); +function startTestServer(deps: ApiDeps): Promise { + const handler = mod.createServerHandler!(deps); const server = http.createServer(handler); return new Promise((resolve) => { server.listen(0, '127.0.0.1', () => resolve(server)); @@ -103,21 +106,26 @@ function startTestServer(client: any, deps: ServerDeps): Promise { // --- Health endpoint --- -test('GET /api/health returns 200 when client is ready', async () => { - const server = await startTestServer(makeClient(), makeDeps()); +test('GET /api/health returns 200 when at least one provider is ready', async () => { + const server = await startTestServer(makeDeps()); try { const res = await request(server, { method: 'GET', path: '/api/health' }); assert.equal(res.status, 200); assert.equal(res.body.success, true); assert.equal(res.body.status, 'ok'); assert.equal(typeof res.body.uptime, 'number'); + assert.deepEqual(res.body.providers, { discord: true }); } finally { server.close(); } }); -test('GET /api/health returns 503 when client is not ready', async () => { - const server = await startTestServer(makeClient({ isReady: () => false }), makeDeps()); +test('GET /api/health returns 503 when no providers are ready', async () => { + const server = await startTestServer( + makeDeps({ + providers: new Map([['discord', makeProvider('discord', { ready: false })]]), + }), + ); try { const res = await request(server, { method: 'GET', path: '/api/health' }); assert.equal(res.status, 503); @@ -129,7 +137,7 @@ test('GET /api/health returns 503 when client is not ready', async () => { }); test('POST /api/health returns 405', async () => { - const server = await startTestServer(makeClient(), makeDeps()); + const server = await startTestServer(makeDeps()); try { const res = await request(server, { method: 'POST', path: '/api/health', body: {} }); assert.equal(res.status, 405); @@ -141,7 +149,7 @@ test('POST /api/health returns 405', async () => { // --- Unknown route --- test('unknown route returns 404', async () => { - const server = await startTestServer(makeClient(), makeDeps()); + const server = await startTestServer(makeDeps()); try { const res = await request(server, { method: 'GET', path: '/api/unknown' }); assert.equal(res.status, 404); @@ -153,7 +161,7 @@ test('unknown route returns 404', async () => { // --- Send endpoint --- test('GET /api/send returns 405', async () => { - const server = await startTestServer(makeClient(), makeDeps()); + const server = await startTestServer(makeDeps()); try { const res = await request(server, { method: 'GET', path: '/api/send' }); assert.equal(res.status, 405); @@ -162,8 +170,12 @@ test('GET /api/send returns 405', async () => { } }); -test('POST /api/send returns 503 when client is not ready', async () => { - const server = await startTestServer(makeClient({ isReady: () => false }), makeDeps()); +test('POST /api/send returns 503 when provider is not ready', async () => { + const server = await startTestServer( + makeDeps({ + providers: new Map([['discord', makeProvider('discord', { ready: false })]]), + }), + ); try { const res = await request(server, { method: 'POST', @@ -171,14 +183,14 @@ test('POST /api/send returns 503 when client is not ready', async () => { body: { agentId: 'a-1', message: 'hi' }, }); assert.equal(res.status, 503); - assert.equal(res.body.error, 'Bot is not connected to Discord'); + assert.match(res.body.error, /not connected/); } finally { server.close(); } }); test('POST /api/send returns 400 for missing fields', async () => { - const server = await startTestServer(makeClient(), makeDeps()); + const server = await startTestServer(makeDeps()); try { const res = await request(server, { method: 'POST', @@ -192,19 +204,13 @@ test('POST /api/send returns 400 for missing fields', async () => { } }); -test('POST /api/send returns 200 on success', async () => { - const sentMessages: string[] = []; - const client = makeClient({ - channels: { - fetch: async () => ({ - isSendable: () => true, - send: async (msg: string) => { - sentMessages.push(msg); - }, - }), - }, - }); - const server = await startTestServer(client, makeDeps()); +test('POST /api/send returns 200 on success and routes to default discord provider', async () => { + const sent: string[] = []; + const server = await startTestServer( + makeDeps({ + providers: new Map([['discord', makeProvider('discord', { sentMessages: sent })]]), + }), + ); try { const res = await request(server, { method: 'POST', @@ -214,26 +220,21 @@ test('POST /api/send returns 200 on success', async () => { assert.equal(res.status, 200); assert.equal(res.body.success, true); assert.equal(res.body.channelId, 'ch-1'); - assert.deepEqual(sentMessages, ['hello']); + assert.deepEqual(sent, ['hello']); } finally { server.close(); } }); -test('POST /api/send prepends mention when mention=true and mentionUserId is set', async () => { - const sentMessages: string[] = []; - const client = makeClient({ - channels: { - fetch: async () => ({ - isSendable: () => true, - send: async (msg: string) => { - sentMessages.push(msg); - }, - }), - }, - }); - const deps = makeDeps({ config: { guildId: 'g-1', apiPort: 0, mentionUserId: '999' } }); - const server = await startTestServer(client, deps); +test('POST /api/send forwards mention=true to the provider on the first part only', async () => { + const sent: string[] = []; + const server = await startTestServer( + makeDeps({ + providers: new Map([['discord', makeProvider('discord', { sentMessages: sent })]]), + // Force a multi-part split so we can verify mention only applies to part 0. + splitMessage: () => ['part-0', 'part-1'], + }), + ); try { const res = await request(server, { method: 'POST', @@ -241,47 +242,63 @@ test('POST /api/send prepends mention when mention=true and mentionUserId is set body: { agentId: 'a-1', message: 'done', mention: true }, }); assert.equal(res.status, 200); - assert.deepEqual(sentMessages, ['<@999> done']); + assert.deepEqual(sent, ['<@MENTION> part-0', 'part-1']); } finally { server.close(); } }); -test('POST /api/send does not mention when mentionUserId is empty', async () => { - const sentMessages: string[] = []; - const client = makeClient({ - channels: { - fetch: async () => ({ - isSendable: () => true, - send: async (msg: string) => { - sentMessages.push(msg); - }, - }), - }, - }); - const server = await startTestServer(client, makeDeps()); +test('POST /api/send routes to the named provider when supplied', async () => { + const discordSent: string[] = []; + const slackSent: string[] = []; + const server = await startTestServer( + makeDeps({ + providers: new Map([ + ['discord', makeProvider('discord', { sentMessages: discordSent })], + ['slack', makeProvider('slack', { sentMessages: slackSent })], + ]), + }), + ); try { const res = await request(server, { method: 'POST', path: '/api/send', - body: { agentId: 'a-1', message: 'done', mention: true }, + body: { agentId: 'a-1', message: 'hello slack', provider: 'slack' }, }); assert.equal(res.status, 200); - assert.deepEqual(sentMessages, ['done']); + assert.deepEqual(slackSent, ['hello slack']); + assert.deepEqual(discordSent, []); + } finally { + server.close(); + } +}); + +test('POST /api/send returns 400 for an unknown provider', async () => { + const server = await startTestServer(makeDeps()); + try { + const res = await request(server, { + method: 'POST', + path: '/api/send', + body: { agentId: 'a-1', message: 'hi', provider: 'matrix' }, + }); + assert.equal(res.status, 400); + assert.match(res.body.error, /Unknown or disabled provider/); } finally { server.close(); } }); test('POST /api/send returns 404 for unknown agent', async () => { - const deps = makeDeps({ - channelDb: { - getByAgentId: () => undefined, - register: () => undefined, - }, - maestro: { listAgents: async () => [{ id: 'other', name: 'Other', toolType: 'x', cwd: '/' }] }, - }); - const server = await startTestServer(makeClient(), deps); + const server = await startTestServer( + makeDeps({ + providers: new Map([ + [ + 'discord', + makeProvider('discord', { findThrows: new Error('Agent not found: missing') }), + ], + ]), + }), + ); try { const res = await request(server, { method: 'POST', @@ -296,7 +313,7 @@ test('POST /api/send returns 404 for unknown agent', async () => { }); test('POST /api/send returns 415 for wrong content type', async () => { - const server = await startTestServer(makeClient(), makeDeps()); + const server = await startTestServer(makeDeps()); try { const addr = server.address() as { port: number }; const res = await new Promise<{ status: number; body: any }>((resolve, reject) => { diff --git a/src/__tests__/session-command.test.ts b/src/__tests__/session-command.test.ts index 29d31dc..bedcfd6 100644 --- a/src/__tests__/session-command.test.ts +++ b/src/__tests__/session-command.test.ts @@ -1,6 +1,6 @@ import test, { afterEach, mock } from 'node:test'; import assert from 'node:assert/strict'; -import { execute } from '../commands/session'; +import { execute } from '../providers/discord/commands/session'; afterEach(() => { mock.restoreAll(); @@ -43,7 +43,8 @@ function makeInteraction(overrides: Record = {}) { // --- /session new --- test('session new creates a thread and registers it', async () => { - const { channelDb, threadDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); + const { threadDb } = await import('../providers/discord/threadsDb'); mock.method(channelDb, 'get', () => ({ channel_id: 'ch-1', agent_id: 'agent-1', @@ -73,7 +74,8 @@ test('session new creates a thread and registers it', async () => { }); test('session new uses provided name', async () => { - const { channelDb, threadDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); + const { threadDb } = await import('../providers/discord/threadsDb'); mock.method(channelDb, 'get', () => ({ channel_id: 'ch-1', agent_id: 'agent-1', @@ -106,7 +108,8 @@ test('session new uses provided name', async () => { }); test('session new from a thread creates a thread on the parent agent channel', async () => { - const { channelDb, threadDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); + const { threadDb } = await import('../providers/discord/threadsDb'); const channelGetMock = mock.method(channelDb, 'get', (id: string) => id === 'parent-ch' ? { channel_id: 'parent-ch', agent_id: 'agent-1', agent_name: 'TestBot' } @@ -144,7 +147,7 @@ test('session new from a thread creates a thread on the parent agent channel', a }); test('session new from a thread whose parent is not an agent channel rejects', async () => { - const { channelDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); mock.method(channelDb, 'get', () => undefined); const interaction = makeInteraction({ @@ -180,7 +183,7 @@ test('session new from a thread with no parentId rejects', async () => { }); test('session new rejects when not in an agent channel', async () => { - const { channelDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); mock.method(channelDb, 'get', () => undefined); const interaction = makeInteraction({ @@ -197,7 +200,8 @@ test('session new rejects when not in an agent channel', async () => { // --- /session list --- test('session list shows threads with session info', async () => { - const { channelDb, threadDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); + const { threadDb } = await import('../providers/discord/threadsDb'); mock.method(channelDb, 'get', () => ({ channel_id: 'ch-1', agent_id: 'agent-1', @@ -214,7 +218,7 @@ test('session list shows threads with session info', async () => { }, ]); - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); mock.method(maestro, 'listSessions', async () => [ { sessionId: 'sess-abc123', @@ -247,7 +251,8 @@ test('session list shows threads with session info', async () => { }); test('session list shows empty message when no threads exist', async () => { - const { channelDb, threadDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); + const { threadDb } = await import('../providers/discord/threadsDb'); mock.method(channelDb, 'get', () => ({ channel_id: 'ch-1', agent_id: 'agent-1', @@ -267,7 +272,8 @@ test('session list shows empty message when no threads exist', async () => { }); test('session list handles maestro session fetch failure gracefully', async () => { - const { channelDb, threadDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); + const { threadDb } = await import('../providers/discord/threadsDb'); mock.method(channelDb, 'get', () => ({ channel_id: 'ch-1', agent_id: 'agent-1', @@ -284,7 +290,7 @@ test('session list handles maestro session fetch failure gracefully', async () = }, ]); - const { maestro } = await import('../services/maestro'); + const { maestro } = await import('../core/maestro'); mock.method(maestro, 'listSessions', async () => { throw new Error('CLI error'); }); @@ -302,7 +308,8 @@ test('session list handles maestro session fetch failure gracefully', async () = }); test('session list from a thread lists threads of the parent agent channel', async () => { - const { channelDb, threadDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); + const { threadDb } = await import('../providers/discord/threadsDb'); mock.method(channelDb, 'get', (id: string) => id === 'parent-ch' ? { channel_id: 'parent-ch', agent_id: 'agent-1', agent_name: 'TestBot' } @@ -332,7 +339,7 @@ test('session list from a thread lists threads of the parent agent channel', asy }); test('session list rejects non-agent channels', async () => { - const { channelDb } = await import('../db'); + const { channelDb } = await import('../providers/discord/channelsDb'); mock.method(channelDb, 'get', () => undefined); const interaction = makeInteraction({ diff --git a/src/__tests__/splitMessage.test.ts b/src/__tests__/splitMessage.test.ts index e961d43..ff228b1 100644 --- a/src/__tests__/splitMessage.test.ts +++ b/src/__tests__/splitMessage.test.ts @@ -1,8 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { splitMessage } from '../utils/splitMessage'; +import { splitMessage, DEFAULT_MAX_LENGTH } from '../core/splitMessage'; -const MAX_LENGTH = 1990; // keep in sync with utils/splitMessage +const MAX_LENGTH = DEFAULT_MAX_LENGTH; test('splitMessage returns a single part when under limit', () => { const input = 'hello world'; diff --git a/src/__tests__/transcription.test.ts b/src/__tests__/transcription.test.ts index 895bbd2..4f929d1 100644 --- a/src/__tests__/transcription.test.ts +++ b/src/__tests__/transcription.test.ts @@ -1,10 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { - MAX_VOICE_ATTACHMENT_BYTES, - isVoiceMessage, - transcribeVoiceAttachment, -} from '../services/transcription'; +import { MAX_VOICE_ATTACHMENT_BYTES, transcribeVoiceAttachment } from '../core/transcription'; +import { isVoiceMessage } from '../providers/discord/voice'; test('isVoiceMessage returns true only when MessageFlags.IsVoiceMessage is set', () => { // MessageFlags.IsVoiceMessage = 8192 diff --git a/src/commands/.gitkeep b/src/commands/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/core/api.ts b/src/core/api.ts new file mode 100644 index 0000000..933ceb5 --- /dev/null +++ b/src/core/api.ts @@ -0,0 +1,223 @@ +import http from 'http'; +import type { BridgeProvider } from './types'; +import { config } from './config'; +import { logger } from './logger'; +import { splitMessage as defaultSplit } from './splitMessage'; + +export interface SendRequest { + agentId: string; + message: string; + mention?: boolean; + /** Optional provider name; defaults to 'discord' for back-compat. */ + provider?: string; +} + +export type ApiDeps = { + /** Map provider-name β†’ BridgeProvider instance. */ + providers: Map; + splitMessage?: (text: string) => string[]; + logger?: { error(...args: unknown[]): unknown }; +}; + +const MAX_BODY_SIZE = 1_048_576; // 1 MB + +export function parseBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + + req.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_BODY_SIZE) { + req.destroy(); + reject(new Error('Request body too large')); + return; + } + chunks.push(chunk); + }); + + req.on('end', () => { + try { + const body = Buffer.concat(chunks).toString(); + resolve(JSON.parse(body) as SendRequest); + } catch { + reject(new Error('Invalid JSON')); + } + }); + + req.on('error', reject); + }); +} + +function sendJson(res: http.ServerResponse, status: number, data: object) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(data)); +} + +export function createServerHandler(deps: ApiDeps) { + const split = deps.splitMessage ?? defaultSplit; + const log = deps.logger ?? logger; + + async function handleSend(req: http.IncomingMessage, res: http.ServerResponse) { + const contentType = req.headers['content-type'] || ''; + if (!contentType.includes('application/json')) { + sendJson(res, 415, { success: false, error: 'Content-Type must be application/json' }); + return; + } + + let body: SendRequest; + try { + body = await parseBody(req); + } catch (err) { + const message = (err as Error).message; + const status = message === 'Request body too large' ? 413 : 400; + sendJson(res, status, { success: false, error: message }); + return; + } + + if ( + !body || + typeof body !== 'object' || + Array.isArray(body) || + typeof body.agentId !== 'string' || + body.agentId.trim() === '' || + typeof body.message !== 'string' || + body.message.trim() === '' + ) { + sendJson(res, 400, { + success: false, + error: 'agentId and message are required non-empty strings', + }); + return; + } + + const providerName = body.provider ?? 'discord'; + const provider = deps.providers.get(providerName); + if (!provider) { + sendJson(res, 400, { + success: false, + error: `Unknown or disabled provider: ${providerName}`, + }); + return; + } + if (!provider.isReady()) { + await log.error('api', `Provider not ready: ${providerName}`); + sendJson(res, 503, { + success: false, + error: `Provider ${providerName} is not connected`, + }); + return; + } + + let info; + try { + info = await provider.findOrCreateAgentChannel(body.agentId); + } catch (err) { + const msg = (err as Error).message; + if (msg.startsWith('Agent not found:')) { + sendJson(res, 404, { success: false, error: msg }); + } else { + await log.error('api/findOrCreateAgentChannel', msg); + sendJson(res, 500, { success: false, error: msg }); + } + return; + } + + const target = { provider: providerName, channelId: info.channelId }; + const parts = split(body.message); + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + let lastError: Error | undefined; + for (let attempt = 0; attempt < 3; attempt++) { + try { + // Mention only on the first part; provider decides how to render. + await provider.send(target, { text: part, mention: i === 0 && !!body.mention }); + lastError = undefined; + break; + } catch (err) { + lastError = err as Error; + const discordErr = err as { status?: number; retryAfter?: number }; + const isRateLimited = discordErr.status === 429 || discordErr.retryAfter != null; + if (isRateLimited) { + const delay = discordErr.retryAfter ?? 1000; + await new Promise((r) => setTimeout(r, delay)); + } else { + break; + } + } + } + if (lastError) { + const discordErr = lastError as Error & { status?: number; retryAfter?: number }; + const isRateLimited = discordErr.status === 429 || discordErr.retryAfter != null; + if (isRateLimited) { + await log.error('api', 'Rate limited by provider after 3 retries'); + sendJson(res, 429, { success: false, error: 'Rate limited, retry later' }); + } else { + await log.error('api', lastError.message); + sendJson(res, 500, { success: false, error: lastError.message }); + } + return; + } + } + + sendJson(res, 200, { success: true, channelId: info.channelId }); + } + + return function handler(req: http.IncomingMessage, res: http.ServerResponse) { + const url = req.url || ''; + + if (url === '/api/health') { + if (req.method !== 'GET') { + sendJson(res, 405, { success: false, error: 'Method not allowed' }); + return; + } + const ready = [...deps.providers.values()].some((p) => p.isReady()); + const providers: Record = {}; + for (const [name, p] of deps.providers) providers[name] = p.isReady(); + sendJson(res, ready ? 200 : 503, { + success: ready, + status: ready ? 'ok' : 'not_ready', + uptime: process.uptime(), + providers, + }); + return; + } + + if (url === '/api/send') { + if (req.method !== 'POST') { + sendJson(res, 405, { success: false, error: 'Method not allowed' }); + return; + } + handleSend(req, res).catch(async (err) => { + const msg = (err as Error).message || 'Internal server error'; + await log.error('api/unhandled', msg); + sendJson(res, 500, { success: false, error: msg }); + }); + return; + } + + sendJson(res, 404, { success: false, error: 'Not found' }); + }; +} + +export function startServer(providers: Map): http.Server { + const handler = createServerHandler({ providers }); + + const server = http.createServer(handler); + + server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + console.error(`API server failed to start: port ${config.apiPort} is already in use`); + } else { + console.error('API server error:', err.message); + } + process.exit(1); + }); + + server.listen(config.apiPort, '127.0.0.1', () => { + console.log(`API server listening on http://127.0.0.1:${config.apiPort}`); + }); + + return server; +} diff --git a/src/utils/attachments.ts b/src/core/attachments.ts similarity index 63% rename from src/utils/attachments.ts rename to src/core/attachments.ts index 1bd8d85..e2b0a33 100644 --- a/src/utils/attachments.ts +++ b/src/core/attachments.ts @@ -1,41 +1,43 @@ import { mkdir, rm, writeFile } from 'fs/promises'; import { randomUUID } from 'crypto'; import path from 'path'; -import type { Collection, Attachment } from 'discord.js'; +import type { IncomingAttachment } from './types'; export interface DownloadedFile { originalName: string; - savedPath: string; // absolute path + savedPath: string; } -export const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB -export const FILES_DIR = '.maestro/discord-files'; - -/** - * Download Discord attachments to the agent's working directory. - * Returns an array of successfully downloaded files (never throws). - */ export interface DownloadResult { downloaded: DownloadedFile[]; - failed: string[]; // original names of files that failed + failed: string[]; } +export const MAX_FILE_SIZE = 25 * 1024 * 1024; +/** Default subdirectory under the agent cwd. Providers may pass their own. */ +export const DEFAULT_FILES_SUBDIR = '.maestro/discord-files'; + +/** + * Download a list of attachments to the agent's working directory. + * Provider-agnostic: each attachment is fetched by URL. + */ export async function downloadAttachments( - attachments: Collection, + attachments: IncomingAttachment[], agentCwd: string, + subdir: string = DEFAULT_FILES_SUBDIR, ): Promise { - const targetDir = path.join(agentCwd, FILES_DIR); + const targetDir = path.join(agentCwd, subdir); try { await mkdir(targetDir, { recursive: true }); } catch (err) { console.warn(`[attachments] Failed to create directory "${targetDir}":`, err); - return { downloaded: [], failed: [...attachments.values()].map((a) => a.name) }; + return { downloaded: [], failed: attachments.map((a) => a.name) }; } const downloaded: DownloadedFile[] = []; const failed: string[] = []; - for (const [, attachment] of attachments) { + for (const attachment of attachments) { if (attachment.size > MAX_FILE_SIZE) { console.warn( `[attachments] Skipping "${attachment.name}" (${attachment.size} bytes) β€” exceeds ${MAX_FILE_SIZE} byte limit`, @@ -70,23 +72,19 @@ export async function downloadAttachments( return { downloaded, failed }; } -/** - * Remove the `.maestro/discord-files/` directory for an agent. - * Silently succeeds if the directory doesn't exist. - */ -export async function cleanupAgentFiles(agentCwd: string): Promise { - const dir = path.join(agentCwd, FILES_DIR); +/** Remove a downloaded-files directory for an agent. Best-effort. */ +export async function cleanupAgentFiles( + agentCwd: string, + subdir: string = DEFAULT_FILES_SUBDIR, +): Promise { + const dir = path.join(agentCwd, subdir); try { await rm(dir, { recursive: true, force: true }); } catch { - // Removal failed (e.g., permission error) β€” nothing to do + // Best-effort cleanup } } -/** - * Format downloaded files as "[Attached: /path]" lines for inclusion in messages. - * Returns empty string if no files. - */ export function formatAttachmentRefs(files: DownloadedFile[]): string { if (files.length === 0) return ''; return files.map((f) => `[Attached: ${f.savedPath}]`).join('\n'); diff --git a/src/config.ts b/src/core/config.ts similarity index 60% rename from src/config.ts rename to src/core/config.ts index 3d42e39..182ede3 100644 --- a/src/config.ts +++ b/src/core/config.ts @@ -7,35 +7,29 @@ export function required(key: string): string { return val; } -function requiredCsv(key: string): string[] { +function csv(key: string): string[] { const val = process.env[key]; if (!val) return []; - return val .split(',') .map((item) => item.trim()) .filter((item) => item.length > 0); } +/** + * Provider-neutral kernel configuration. Each provider adapter loads its + * own platform credentials (DISCORD_BOT_TOKEN, SLACK_BOT_TOKEN, ...) on + * `start()` so missing creds for a disabled provider don't fail the bot. + */ export const config = { - get token() { - return required('DISCORD_BOT_TOKEN'); - }, - get clientId() { - return required('DISCORD_CLIENT_ID'); - }, - get guildId() { - return required('DISCORD_GUILD_ID'); - }, - get allowedUserIds() { - return requiredCsv('DISCORD_ALLOWED_USER_IDS'); + /** Comma-separated list of provider names to enable, e.g. `discord` or `discord,slack`. */ + get enabledProviders(): string[] { + const raw = csv('ENABLED_PROVIDERS'); + return raw.length > 0 ? raw : ['discord']; }, get apiPort() { return parseInt(process.env.API_PORT || '3457', 10); }, - get mentionUserId() { - return process.env.DISCORD_MENTION_USER_ID || ''; - }, get ffmpegPath() { return process.env.FFMPEG_PATH || 'ffmpeg'; }, diff --git a/src/core/db/index.ts b/src/core/db/index.ts new file mode 100644 index 0000000..3b24333 --- /dev/null +++ b/src/core/db/index.ts @@ -0,0 +1,97 @@ +import Database from 'better-sqlite3'; +import path from 'path'; +import { runMigrations } from './migrations'; + +/** + * Provider-aware channel registry. Each row binds a (provider, channel_id) + * pair to a maestro agent and (optionally) an active session. Provider- + * specific tables (e.g. Discord threads) live alongside this in adapter- + * owned modules. + */ + +export const db = new Database(path.join(__dirname, '../../../maestro-bot.db')); + +db.exec(` + CREATE TABLE IF NOT EXISTS agent_channels ( + provider TEXT NOT NULL DEFAULT 'discord', + channel_id TEXT NOT NULL, + guild_id TEXT, + agent_id TEXT NOT NULL, + agent_name TEXT NOT NULL, + session_id TEXT, + read_only INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (provider, channel_id) + ) +`); + +runMigrations(db); + +export interface AgentChannel { + provider: string; + channel_id: string; + guild_id: string | null; + agent_id: string; + agent_name: string; + session_id: string | null; + read_only: number; + created_at: number; +} + +export const channelDb = { + register( + provider: string, + channelId: string, + agentId: string, + agentName: string, + guildId: string | null = null, + ): void { + db.prepare( + `INSERT INTO agent_channels (provider, channel_id, guild_id, agent_id, agent_name) + VALUES (?, ?, ?, ?, ?)`, + ).run(provider, channelId, guildId, agentId, agentName); + }, + + get(provider: string, channelId: string): AgentChannel | undefined { + return db + .prepare('SELECT * FROM agent_channels WHERE provider = ? AND channel_id = ?') + .get(provider, channelId) as AgentChannel | undefined; + }, + + getByAgentId(provider: string, agentId: string): AgentChannel | undefined { + return db + .prepare('SELECT * FROM agent_channels WHERE provider = ? AND agent_id = ?') + .get(provider, agentId) as AgentChannel | undefined; + }, + + updateSession(provider: string, channelId: string, sessionId: string | null): void { + db.prepare( + 'UPDATE agent_channels SET session_id = ? WHERE provider = ? AND channel_id = ?', + ).run(sessionId, provider, channelId); + }, + + setReadOnly(provider: string, channelId: string, readOnly: boolean): void { + db.prepare( + 'UPDATE agent_channels SET read_only = ? WHERE provider = ? AND channel_id = ?', + ).run(readOnly ? 1 : 0, provider, channelId); + }, + + remove(provider: string, channelId: string): void { + db.prepare('DELETE FROM agent_channels WHERE provider = ? AND channel_id = ?').run( + provider, + channelId, + ); + }, + + listByAgentId(provider: string, agentId: string): AgentChannel[] { + return db + .prepare('SELECT * FROM agent_channels WHERE provider = ? AND agent_id = ?') + .all(provider, agentId) as AgentChannel[]; + }, + + listByGuild(guildId: string): AgentChannel[] { + return db + .prepare("SELECT * FROM agent_channels WHERE provider = 'discord' AND guild_id = ?") + .all(guildId) as AgentChannel[]; + }, +}; diff --git a/src/core/db/migrations.ts b/src/core/db/migrations.ts new file mode 100644 index 0000000..ae9d26a --- /dev/null +++ b/src/core/db/migrations.ts @@ -0,0 +1,121 @@ +import type Database from 'better-sqlite3'; + +/** + * Idempotent schema migrations. Runs on startup; safe to re-run. + * + * Migration history: + * 1. Add `read_only` to agent_channels (legacy) + * 2. Add `owner_user_id` to agent_threads (legacy) + * 3. Add `provider` column + composite PK (provider, channel_id) to agent_channels + * 4. Rename `agent_threads` β†’ `discord_agent_threads` + */ +export function runMigrations(db: Database.Database): void { + ensureReadOnlyColumn(db); + ensureProviderColumn(db); + renameAgentThreadsTable(db); + ensureDiscordThreadsTable(db); + ensureOwnerUserIdColumn(db); +} + +export function ensureOwnerUserIdColumn(database: Database.Database): void { + try { + database.exec('ALTER TABLE discord_agent_threads ADD COLUMN owner_user_id TEXT'); + } catch (error) { + if ( + !(error instanceof Error) || + !error.message.toLowerCase().includes('duplicate column name') + ) { + throw error; + } + } +} + +export function ensureReadOnlyColumn(database: Database.Database): void { + try { + database.exec('ALTER TABLE agent_channels ADD COLUMN read_only INTEGER DEFAULT 0'); + } catch (error) { + if ( + !(error instanceof Error) || + !error.message.toLowerCase().includes('duplicate column name') + ) { + throw error; + } + } +} + +/** + * Add `provider` column to agent_channels and re-create the table with a + * composite PK (provider, channel_id). Existing rows default to 'discord'. + */ +function ensureProviderColumn(database: Database.Database): void { + const cols = database + .prepare("PRAGMA table_info('agent_channels')") + .all() as Array<{ name: string }>; + const hasProvider = cols.some((c) => c.name === 'provider'); + if (hasProvider) return; + if (cols.length === 0) return; // table doesn't exist yet β€” index.ts CREATE handles it + + // Re-create the table with the new schema, copy data, swap. SQLite cannot + // change a primary key in place. + database.exec('BEGIN'); + try { + database.exec(` + CREATE TABLE agent_channels_new ( + provider TEXT NOT NULL DEFAULT 'discord', + channel_id TEXT NOT NULL, + guild_id TEXT, + agent_id TEXT NOT NULL, + agent_name TEXT NOT NULL, + session_id TEXT, + read_only INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (provider, channel_id) + ) + `); + database.exec(` + INSERT INTO agent_channels_new (provider, channel_id, guild_id, agent_id, agent_name, session_id, read_only, created_at) + SELECT 'discord', channel_id, guild_id, agent_id, agent_name, session_id, COALESCE(read_only, 0), created_at + FROM agent_channels + `); + database.exec('DROP TABLE agent_channels'); + database.exec('ALTER TABLE agent_channels_new RENAME TO agent_channels'); + database.exec('COMMIT'); + } catch (err) { + database.exec('ROLLBACK'); + throw err; + } +} + +function renameAgentThreadsTable(database: Database.Database): void { + const oldExists = + ( + database + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='agent_threads'") + .get() as { name?: string } | undefined + )?.name === 'agent_threads'; + const newExists = + ( + database + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='discord_agent_threads'", + ) + .get() as { name?: string } | undefined + )?.name === 'discord_agent_threads'; + + if (oldExists && !newExists) { + database.exec('ALTER TABLE agent_threads RENAME TO discord_agent_threads'); + } +} + +function ensureDiscordThreadsTable(database: Database.Database): void { + database.exec(` + CREATE TABLE IF NOT EXISTS discord_agent_threads ( + thread_id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + owner_user_id TEXT, + session_id TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) + ) + `); +} diff --git a/src/services/logger.ts b/src/core/logger.ts similarity index 100% rename from src/services/logger.ts rename to src/core/logger.ts diff --git a/src/services/maestro.ts b/src/core/maestro.ts similarity index 100% rename from src/services/maestro.ts rename to src/core/maestro.ts diff --git a/src/core/providers.ts b/src/core/providers.ts new file mode 100644 index 0000000..7809119 --- /dev/null +++ b/src/core/providers.ts @@ -0,0 +1,29 @@ +import type { BridgeProvider } from './types'; + +/** + * Build the set of provider instances enabled in this deployment. + * Adapters are dynamically imported so a disabled provider never loads + * its config (and never fails on missing platform credentials). + */ +export async function buildProviders( + enabled: string[], +): Promise> { + const providers = new Map(); + for (const name of enabled) { + const adapter = await loadProvider(name); + if (adapter) providers.set(adapter.name, adapter); + } + return providers; +} + +async function loadProvider(name: string): Promise { + switch (name) { + case 'discord': { + const { DiscordProvider } = await import('../providers/discord/adapter'); + return new DiscordProvider(); + } + default: + console.warn(`[providers] Unknown provider "${name}" β€” ignoring.`); + return null; + } +} diff --git a/src/core/queue.ts b/src/core/queue.ts new file mode 100644 index 0000000..b036ef8 --- /dev/null +++ b/src/core/queue.ts @@ -0,0 +1,227 @@ +import type { + BridgeProvider, + EnqueueOptions, + IncomingAttachment, + IncomingMessage, + KernelLogger, + ReactionHandle, +} from './types'; +import { splitMessage as defaultSplitMessage } from './splitMessage'; +import { downloadAttachments as defaultDownload, formatAttachmentRefs } from './attachments'; + +interface QueueEntry { + message: IncomingMessage; + options?: EnqueueOptions; +} + +export type QueueDeps = { + /** Maestro CLI surface needed by the queue. */ + maestro: { + getAgentCwd: (agentId: string) => Promise; + send: ( + agentId: string, + message: string, + sessionId?: string, + readOnly?: boolean, + ) => Promise<{ + success: boolean; + response: string | null; + error?: string; + sessionId?: string; + usage?: { + inputTokens?: number; + outputTokens?: number; + totalCostUsd?: number; + contextUsagePercent?: number; + }; + }>; + }; + /** Resolves provider name β†’ BridgeProvider instance. */ + getProvider: (name: string) => BridgeProvider | undefined; + splitMessage?: (text: string) => string[]; + downloadAttachments?: ( + attachments: IncomingAttachment[], + agentCwd: string, + ) => Promise<{ + downloaded: { originalName: string; savedPath: string }[]; + failed: string[]; + }>; + formatAttachmentRefs?: (files: { originalName: string; savedPath: string }[]) => string; + logger: KernelLogger; +}; + +/** + * Build a per-conversation FIFO queue. Each conversation (provider+channel) + * is processed serially; multiple conversations run concurrently. + * + * The queue is provider-agnostic β€” it speaks only via the BridgeProvider + * interface (send / react / sendTyping) and the maestro CLI wrapper. + */ +export function createQueue(deps: QueueDeps) { + const split = deps.splitMessage ?? defaultSplitMessage; + const download = deps.downloadAttachments ?? defaultDownload; + const fmtAttachments = deps.formatAttachmentRefs ?? formatAttachmentRefs; + + const queues = new Map(); + const processing = new Set(); + + function key(message: IncomingMessage): string { + return `${message.provider}:${message.channelId}`; + } + + function enqueue(message: IncomingMessage, options?: EnqueueOptions): void { + const k = key(message); + if (!queues.has(k)) queues.set(k, []); + queues.get(k)!.push({ message, options }); + + if (!processing.has(k)) { + void processNext(k); + } + } + + async function processNext(k: string): Promise { + const queue = queues.get(k); + if (!queue || queue.length === 0) { + processing.delete(k); + return; + } + + processing.add(k); + const { message, options } = queue.shift()!; + + const provider = deps.getProvider(message.provider); + if (!provider) { + void deps.logger.error( + 'queue:no-provider', + `unknown provider="${message.provider}" channel=${message.channelId}`, + ); + void processNext(k); + return; + } + + const conv = provider.resolveConversation(message); + if (!conv) { + void processNext(k); + return; + } + + const target = { provider: message.provider, channelId: message.channelId }; + const messageTarget = { ...target, messageId: message.messageId }; + + let reaction: ReactionHandle | undefined; + if (provider.react) { + try { + reaction = await provider.react(messageTarget, '⏳'); + } catch { + // best-effort indicator; ignore failures + } + } + + const typingInterval = provider.sendTyping + ? setInterval(() => { + provider.sendTyping?.(target).catch(() => {}); + }, 8000) + : null; + if (provider.sendTyping) { + provider.sendTyping(target).catch(() => {}); + } + + try { + let attachmentRefs = ''; + const attachmentsToProcess = options?.attachmentsOverride ?? message.attachments; + if (attachmentsToProcess.length > 0) { + try { + const agentCwd = await deps.maestro.getAgentCwd(conv.agentId); + if (agentCwd) { + const result = await download(attachmentsToProcess, agentCwd); + attachmentRefs = fmtAttachments(result.downloaded); + if (result.failed.length > 0) { + await provider.send(target, { + text: `⚠️ Failed to download: ${result.failed.join(', ')}. Sending message without those files.`, + }); + } + } else { + await provider.send(target, { + text: '⚠️ Could not resolve agent working directory for file downloads.', + }); + } + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + void deps.logger.error( + 'queue:attachment-download', + `agent=${conv.agentId} channel=${message.channelId} error=${errMsg}`, + ); + await provider.send(target, { + text: '⚠️ Failed to download attachments. Sending message without them.', + }); + } + } + + const fullMessage = [options?.contentOverride ?? message.content, attachmentRefs] + .filter(Boolean) + .join('\n\n'); + const result = await deps.maestro.send( + conv.agentId, + fullMessage, + conv.sessionId ?? undefined, + conv.readOnly, + ); + + if (!conv.sessionId && result.sessionId) { + conv.persistSession(result.sessionId); + } + + if (typingInterval) clearInterval(typingInterval); + + try { + await reaction?.remove(); + } catch { + // ignore cleanup failure + } + + if (!result.success || !result.response) { + const reason = result.error ?? 'The agent could not complete this request.'; + const hint = conv.readOnly + ? '\n-# The agent is in **read-only** mode and cannot modify files.' + : ''; + void deps.logger.error( + 'queue:agent-failure', + `agent=${conv.agentId} session=${conv.sessionId ?? 'new'} channel=${message.channelId} reason=${reason}`, + ); + await provider.send(target, { text: `⚠️ ${reason}${hint}` }); + } else { + const parts = split(result.response); + for (const part of parts) { + await provider.send(target, { text: part }); + } + } + + const cost = (result.usage?.totalCostUsd ?? 0).toFixed(4); + const ctx = (result.usage?.contextUsagePercent ?? 0).toFixed(1); + const tokens = (result.usage?.inputTokens ?? 0) + (result.usage?.outputTokens ?? 0); + await provider.send(target, { + text: `-# πŸ’¬ ${tokens} tokens β€’ $${cost} β€’ ${ctx}% context${conv.readOnly ? ' β€’ πŸ“– read-only' : ''}`, + }); + } catch (err) { + if (typingInterval) clearInterval(typingInterval); + try { + await reaction?.remove(); + } catch { + /* best-effort */ + } + + const errMsg = err instanceof Error ? err.message : String(err); + void deps.logger.error( + 'queue:send-error', + `agent=${conv.agentId} session=${conv.sessionId ?? 'new'} channel=${message.channelId} error=${errMsg}`, + ); + await provider.send(target, { + text: `❌ Failed to get response from agent:\n\`\`\`\n${errMsg}\n\`\`\``, + }); + } + + void processNext(k); + } + + return { enqueue }; +} diff --git a/src/core/splitMessage.ts b/src/core/splitMessage.ts new file mode 100644 index 0000000..6d6cba4 --- /dev/null +++ b/src/core/splitMessage.ts @@ -0,0 +1,23 @@ +export const DEFAULT_MAX_LENGTH = 1990; + +/** + * Split a string into chunks that fit within `maxLength`. + * Tries to split on newlines when possible to preserve formatting. + */ +export function splitMessage(text: string, maxLength: number = DEFAULT_MAX_LENGTH): string[] { + if (text.length <= maxLength) return [text]; + + const parts: string[] = []; + let remaining = text; + + while (remaining.length > maxLength) { + let splitAt = remaining.lastIndexOf('\n', maxLength); + if (splitAt <= 0) splitAt = maxLength; + + parts.push(remaining.slice(0, splitAt)); + remaining = remaining.slice(splitAt).trimStart(); + } + + if (remaining.length > 0) parts.push(remaining); + return parts; +} diff --git a/src/services/transcription.ts b/src/core/transcription.ts similarity index 72% rename from src/services/transcription.ts rename to src/core/transcription.ts index 849bc29..b13224d 100644 --- a/src/services/transcription.ts +++ b/src/core/transcription.ts @@ -5,15 +5,17 @@ import { mkdir, readFile, rm, writeFile, access } from 'fs/promises'; import os from 'os'; import path from 'path'; import { promisify } from 'util'; -import { MessageFlags } from 'discord.js'; -import type { Attachment, Message } from 'discord.js'; -import { config } from '../config'; +import { config } from './config'; import { logger } from './logger'; +import type { IncomingAttachment } from './types'; + +/** + * Voice transcription pipeline. Provider-agnostic: accepts a generic + * IncomingAttachment, downloads it, transcodes via ffmpeg, transcribes + * via whisper-cli. Each provider decides which messages/attachments to + * route through this. + */ -// Hard cap on a single voice attachment's size before we attempt to download -// and transcode it. Discord voice messages are short (≀ 10 min) and small in -// practice; this keeps a stray oversized .ogg from blocking the per-channel -// queue on a 5-minute ffmpeg/whisper run. export const MAX_VOICE_ATTACHMENT_BYTES = 25 * 1024 * 1024; const execFileAsync = promisify(execFile); @@ -26,22 +28,20 @@ async function resolveExecutable(configPath: string, executableName: string): Pr const isAbsolutePath = path.isAbsolute(configPath); if (isAbsolutePath) { - // Validate explicit path is executable await access(configPath, constants.X_OK); return configPath; } - // Bare command name: probe via execution to use OS PATH resolution try { await execFileAsync(configPath, ['--help'], { timeout: 5000 }); return configPath; } catch (err: unknown) { const e = err as { code?: string; message?: string }; - // Only fail if executable is truly missing or not executable if (e.code === 'ENOENT' || e.code === 'EACCES') { - throw new Error(`Could not resolve ${executableName} in PATH or as executable`); + throw new Error(`Could not resolve ${executableName} in PATH or as executable`, { + cause: err, + }); } - // If it ran but exited with non-zero, the executable exists return configPath; } } @@ -61,21 +61,18 @@ export function isTranscriberAvailable(): boolean { export async function checkTranscriptionDependencies(): Promise { const missing: string[] = []; - // Check and resolve ffmpeg executable try { resolvedFfmpegPath = await resolveExecutable(config.ffmpegPath, 'ffmpeg'); } catch { missing.push(`ffmpeg (${config.ffmpegPath})`); } - // Check and resolve whisper-cli executable try { resolvedWhisperCliPath = await resolveExecutable(config.whisperCliPath, 'whisper-cli'); } catch { missing.push(`whisper-cli (${config.whisperCliPath})`); } - // Check whisper model file try { await access(config.whisperModelPath); } catch { @@ -85,7 +82,7 @@ export async function checkTranscriptionDependencies(): Promise { if (missing.length > 0) { console.warn( `⚠️ Transcription disabled: missing dependencies: ${missing.join(', ')}. ` + - 'Voice message transcription will be unavailable. See README for setup instructions.', + 'Voice message transcription will be unavailable. See README for setup instructions.', ); transcriberAvailable = false; } else { @@ -98,7 +95,12 @@ async function runCommand(executable: string, args: string[]): Promise { try { await execFileAsync(executable, args, { timeout: 300000, killSignal: 'SIGKILL' }); } catch (err: unknown) { - const e = err as { message?: string; stderr?: string; stdout?: string; code?: number | string }; + const e = err as { + message?: string; + stderr?: string; + stdout?: string; + code?: number | string; + }; const detail = [e.code ? `exit code: ${e.code}` : '', e.stderr?.trim(), e.stdout?.trim()] .filter(Boolean) .join(' | '); @@ -108,24 +110,19 @@ async function runCommand(executable: string, args: string[]): Promise { } } -export function isVoiceMessage(message: Pick): boolean { - return !!message.flags?.has(MessageFlags.IsVoiceMessage); -} - -export function isVoiceAttachment(attachment: Attachment): boolean { - const contentType = attachment.contentType?.toLowerCase() ?? ''; - const name = attachment.name.toLowerCase(); - return contentType === 'audio/ogg' || name.endsWith('.ogg'); -} - -export async function transcribeVoiceAttachment(attachment: Attachment): Promise { +/** + * Transcribe a voice attachment using ffmpeg + whisper-cli. Operates on a + * generic IncomingAttachment so each provider can decide how to extract its + * voice payload. + */ +export async function transcribeVoiceAttachment(attachment: IncomingAttachment): Promise { if (typeof attachment.size === 'number' && attachment.size > MAX_VOICE_ATTACHMENT_BYTES) { throw new Error( `Voice attachment is ${attachment.size} bytes, exceeds limit of ${MAX_VOICE_ATTACHMENT_BYTES} bytes.`, ); } - const tempDir = path.join(os.tmpdir(), `maestro-discord-voice-${randomUUID()}`); + const tempDir = path.join(os.tmpdir(), `maestro-bridge-voice-${randomUUID()}`); const inputPath = path.join(tempDir, 'input.ogg'); const wavPath = path.join(tempDir, 'input.wav'); const outputBase = path.join(tempDir, 'transcript'); @@ -173,7 +170,16 @@ export async function transcribeVoiceAttachment(attachment: Attachment): Promise return transcription; } finally { await rm(tempDir, { recursive: true, force: true }).catch((err) => { - logger.error('transcription', `Failed to clean up temp transcription files at "${tempDir}": ${err.message || err}`); + logger.error( + 'transcription', + `Failed to clean up temp transcription files at "${tempDir}": ${err.message || err}`, + ); }); } } + +/** Common heuristic: voice messages are .ogg / audio/ogg. */ +export function isVoiceContentType(contentType: string | undefined, name: string): boolean { + const ct = contentType?.toLowerCase() ?? ''; + return ct === 'audio/ogg' || name.toLowerCase().endsWith('.ogg'); +} diff --git a/src/core/types.ts b/src/core/types.ts new file mode 100644 index 0000000..ca67730 --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,123 @@ +/** + * Core types for the bridge kernel. + * + * The kernel is provider-agnostic: it speaks only in the types declared here. + * Each chat provider (Discord, Slack, Teams, ...) ships an adapter that + * implements `BridgeProvider` and translates platform events into + * `IncomingMessage` and platform actions out of `OutgoingMessage`. + */ + +export type ProviderName = string; + +export interface ChannelTarget { + provider: ProviderName; + /** Conversation id β€” the channel id, or the thread/sub-conversation id if applicable. */ + channelId: string; +} + +export interface MessageTarget extends ChannelTarget { + messageId: string; +} + +export interface IncomingAttachment { + url: string; + name: string; + size: number; + contentType?: string; +} + +export interface IncomingMessage { + provider: ProviderName; + messageId: string; + /** Conversation id β€” equal to threadId for thread messages, channelId otherwise. */ + channelId: string; + authorId: string; + authorName: string; + content: string; + attachments: IncomingAttachment[]; + /** True when the message is in a sub-conversation (Discord thread, Slack thread reply, etc.). */ + isThread: boolean; + /** Adapter-internal payload (raw discord.js Message, Slack event, etc.). Opaque to the kernel. */ + raw?: unknown; +} + +export interface OutgoingMessage { + text: string; + /** + * When true, render a user mention/notification alongside the text. + * The provider decides the target (Discord uses DISCORD_MENTION_USER_ID, + * Slack would use SLACK_MENTION_USER_ID, etc.). + */ + mention?: boolean; +} + +/** + * Per-conversation state the queue needs to drive a maestro send. + * Returned by the provider for each incoming message; encapsulates + * the provider-specific channel-vs-thread storage decision. + */ +export interface ConversationRecord { + agentId: string; + sessionId: string | null; + readOnly: boolean; + /** Persist the maestro session id once the first response returns. */ + persistSession(sessionId: string): void; +} + +export interface ReactionHandle { + remove(): Promise; +} + +export interface AgentChannelInfo { + channelId: string; + agentId: string; + agentName: string; +} + +export interface BridgeProvider { + readonly name: ProviderName; + + /** Connect to the platform and register event handlers. */ + start(ctx: KernelContext): Promise; + + /** Disconnect and release resources. */ + stop(): Promise; + + /** + * Resolve the conversation context for an incoming message. Returns null if + * the channel is not registered to an agent (and the kernel should drop the message). + */ + resolveConversation(message: IncomingMessage): ConversationRecord | null; + + /** Send a message into a conversation. */ + send(target: ChannelTarget, msg: OutgoingMessage): Promise; + + /** + * Look up (or create) the platform channel bound to a given agent. + * Used by the HTTP API for agent-initiated messages. + */ + findOrCreateAgentChannel(agentId: string): Promise; + + /** Optional: react to a message (used as a "queued" indicator). */ + react?(target: MessageTarget, emoji: string): Promise; + + /** Optional: emit a typing indicator while the agent thinks. */ + sendTyping?(target: ChannelTarget): Promise; + + /** Provider readiness β€” used by /api/health. */ + isReady(): boolean; +} + +export type EnqueueOptions = { + contentOverride?: string; + attachmentsOverride?: IncomingAttachment[]; +}; + +export interface KernelLogger { + error(context: string, detail: string): void | Promise; +} + +export interface KernelContext { + enqueue(message: IncomingMessage, options?: EnqueueOptions): void; + logger: KernelLogger; +} diff --git a/src/db/index.ts b/src/db/index.ts deleted file mode 100644 index 96fe105..0000000 --- a/src/db/index.ts +++ /dev/null @@ -1,145 +0,0 @@ -import Database from 'better-sqlite3'; -import path from 'path'; -import { ensureOwnerUserIdColumn, ensureReadOnlyColumn } from './migrations'; - -const db = new Database(path.join(__dirname, '../../maestro-bot.db')); - -db.exec(` - CREATE TABLE IF NOT EXISTS agent_channels ( - channel_id TEXT PRIMARY KEY, - guild_id TEXT NOT NULL, - agent_id TEXT NOT NULL, - agent_name TEXT NOT NULL, - session_id TEXT, - created_at INTEGER NOT NULL DEFAULT (unixepoch()) - ) -`); - -db.exec(` - CREATE TABLE IF NOT EXISTS agent_threads ( - thread_id TEXT PRIMARY KEY, - channel_id TEXT NOT NULL, - agent_id TEXT NOT NULL, - owner_user_id TEXT, - session_id TEXT, - created_at INTEGER NOT NULL DEFAULT (unixepoch()) - ) -`); -ensureOwnerUserIdColumn(db); -ensureReadOnlyColumn(db); - -export interface AgentChannel { - channel_id: string; - guild_id: string; - agent_id: string; - agent_name: string; - session_id: string | null; - read_only: number; - created_at: number; -} - -export const channelDb = { - register(channelId: string, guildId: string, agentId: string, agentName: string): void { - db.prepare( - ` - INSERT INTO agent_channels (channel_id, guild_id, agent_id, agent_name) - VALUES (?, ?, ?, ?) - `, - ).run(channelId, guildId, agentId, agentName); - }, - - get(channelId: string): AgentChannel | undefined { - return db.prepare('SELECT * FROM agent_channels WHERE channel_id = ?').get(channelId) as - | AgentChannel - | undefined; - }, - - getByAgentId(agentId: string): AgentChannel | undefined { - return db.prepare('SELECT * FROM agent_channels WHERE agent_id = ?').get(agentId) as - | AgentChannel - | undefined; - }, - - updateSession(channelId: string, sessionId: string | null): void { - db.prepare('UPDATE agent_channels SET session_id = ? WHERE channel_id = ?').run( - sessionId, - channelId, - ); - }, - - setReadOnly(channelId: string, readOnly: boolean): void { - db.prepare('UPDATE agent_channels SET read_only = ? WHERE channel_id = ?').run( - readOnly ? 1 : 0, - channelId, - ); - }, - - remove(channelId: string): void { - db.prepare('DELETE FROM agent_channels WHERE channel_id = ?').run(channelId); - }, - - listByAgentId(agentId: string): AgentChannel[] { - return db - .prepare('SELECT * FROM agent_channels WHERE agent_id = ?') - .all(agentId) as AgentChannel[]; - }, - - listByGuild(guildId: string): AgentChannel[] { - return db - .prepare('SELECT * FROM agent_channels WHERE guild_id = ?') - .all(guildId) as AgentChannel[]; - }, -}; - -export interface AgentThread { - thread_id: string; - channel_id: string; - agent_id: string; - owner_user_id: string | null; - session_id: string | null; - created_at: number; -} - -export const threadDb = { - register(threadId: string, channelId: string, agentId: string, ownerUserId: string): void { - db.prepare( - ` - INSERT INTO agent_threads (thread_id, channel_id, agent_id, owner_user_id) - VALUES (?, ?, ?, ?) - `, - ).run(threadId, channelId, agentId, ownerUserId); - }, - - get(threadId: string): AgentThread | undefined { - return db.prepare('SELECT * FROM agent_threads WHERE thread_id = ?').get(threadId) as - | AgentThread - | undefined; - }, - - updateSession(threadId: string, sessionId: string | null): void { - db.prepare('UPDATE agent_threads SET session_id = ? WHERE thread_id = ?').run( - sessionId, - threadId, - ); - }, - - listByChannel(channelId: string): AgentThread[] { - return db - .prepare('SELECT * FROM agent_threads WHERE channel_id = ? ORDER BY created_at DESC') - .all(channelId) as AgentThread[]; - }, - - remove(threadId: string): void { - db.prepare('DELETE FROM agent_threads WHERE thread_id = ?').run(threadId); - }, - - getByAgentId(agentId: string): AgentThread[] { - return db - .prepare('SELECT * FROM agent_threads WHERE agent_id = ?') - .all(agentId) as AgentThread[]; - }, - - removeByChannel(channelId: string): void { - db.prepare('DELETE FROM agent_threads WHERE channel_id = ?').run(channelId); - }, -}; diff --git a/src/db/migrations.ts b/src/db/migrations.ts deleted file mode 100644 index 791f283..0000000 --- a/src/db/migrations.ts +++ /dev/null @@ -1,27 +0,0 @@ -import Database from 'better-sqlite3'; - -export function ensureOwnerUserIdColumn(database: Database.Database): void { - try { - database.exec('ALTER TABLE agent_threads ADD COLUMN owner_user_id TEXT'); - } catch (error) { - if ( - !(error instanceof Error) || - !error.message.toLowerCase().includes('duplicate column name') - ) { - throw error; - } - } -} - -export function ensureReadOnlyColumn(database: Database.Database): void { - try { - database.exec('ALTER TABLE agent_channels ADD COLUMN read_only INTEGER DEFAULT 0'); - } catch (error) { - if ( - !(error instanceof Error) || - !error.message.toLowerCase().includes('duplicate column name') - ) { - throw error; - } - } -} diff --git a/src/handlers/.gitkeep b/src/handlers/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/index.ts b/src/index.ts index 88c6437..3ecf6ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,113 +1,59 @@ -import { - AutocompleteInteraction, - ChatInputCommandInteraction, - Client, - GatewayIntentBits, - Interaction, - SlashCommandBuilder, -} from 'discord.js'; -import { config } from './config'; -import * as health from './commands/health'; -import * as agents from './commands/agents'; -import * as session from './commands/session'; -import * as playbook from './commands/playbook'; -import * as gist from './commands/gist'; -import * as notes from './commands/notes'; -import * as autoRun from './commands/auto-run'; -import './db'; // ensure DB is initialized on startup - -interface CommandModule { - data: { name: string } & Pick; - execute(interaction: ChatInputCommandInteraction): Promise; - autocomplete?(interaction: AutocompleteInteraction): Promise; -} -import { checkTranscriptionDependencies } from './services/transcription'; -import { handleMessageCreate } from './handlers/messageCreate'; -import { startServer } from './server'; - -const commands = new Map([ - [health.data.name, health], - [agents.data.name, agents], - [session.data.name, session], - [playbook.data.name, playbook], - [gist.data.name, gist], - [notes.data.name, notes], - [autoRun.data.name, autoRun], -]); - -const client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildMessages, - GatewayIntentBits.MessageContent, - ], -}); - -let server: ReturnType | null = null; +import './core/db'; // ensure DB is initialized + migrated on startup +import { config } from './core/config'; +import { logger } from './core/logger'; +import { maestro } from './core/maestro'; +import { createQueue } from './core/queue'; +import { startServer } from './core/api'; +import { buildProviders } from './core/providers'; +import type { KernelContext } from './core/types'; + +async function main() { + const providers = await buildProviders(config.enabledProviders); + if (providers.size === 0) { + console.error( + `No providers enabled. Set ENABLED_PROVIDERS in .env (default 'discord'). Exiting.`, + ); + process.exit(1); + } -client.once('ready', async (c) => { - console.log(`Logged in as ${c.user.tag}`); - await checkTranscriptionDependencies(); - server = startServer(client); -}); + const queue = createQueue({ + maestro, + getProvider: (name) => providers.get(name), + logger, + }); + + const ctx: KernelContext = { + enqueue: queue.enqueue, + logger, + }; + + for (const [name, provider] of providers) { + try { + await provider.start(ctx); + console.log(`[bridge] provider "${name}" started`); + } catch (err) { + console.error(`[bridge] provider "${name}" failed to start:`, err); + process.exit(1); + } + } -client.on('interactionCreate', async (interaction: Interaction) => { - const isUnauthorized = - config.allowedUserIds.length > 0 && !config.allowedUserIds.includes(interaction.user.id); + const server = startServer(providers); - if (interaction.isAutocomplete()) { - if (isUnauthorized) { - await interaction.respond([]); - return; - } - const cmd = commands.get(interaction.commandName); - if (cmd?.autocomplete) { + const shutdown = async (signal: string) => { + console.log(`\n[bridge] received ${signal}, shutting down...`); + server.close(); + for (const [name, provider] of providers) { try { - await cmd.autocomplete(interaction); + await provider.stop(); } catch (err) { - console.error('Autocomplete error:', err); + console.error(`[bridge] error stopping provider "${name}":`, err); } } - return; - } - - if (!interaction.isChatInputCommand()) return; - if (isUnauthorized) { - await interaction.reply({ - content: '❌ You are not authorized to use this bot.', - ephemeral: true, - }); - return; - } + process.exit(0); + }; - const cmd = commands.get(interaction.commandName); - if (!cmd) return; - try { - await cmd.execute(interaction); - } catch (err) { - console.error('Command error:', err); - const msg = { content: '❌ An error occurred.', ephemeral: true }; - if (interaction.replied || interaction.deferred) { - await interaction.followUp(msg); - } else { - await interaction.reply(msg); - } - } -}); - -client.on('messageCreate', handleMessageCreate); - -process.on('SIGINT', () => { - console.log('\nShutting down...'); - server?.close(); - client.destroy(); - process.exit(0); -}); - -process.on('SIGTERM', () => { - server?.close(); - client.destroy(); - process.exit(0); -}); + process.on('SIGINT', () => void shutdown('SIGINT')); + process.on('SIGTERM', () => void shutdown('SIGTERM')); +} -client.login(config.token); +void main(); diff --git a/src/providers/discord/adapter.ts b/src/providers/discord/adapter.ts new file mode 100644 index 0000000..e928abc --- /dev/null +++ b/src/providers/discord/adapter.ts @@ -0,0 +1,276 @@ +import { + AutocompleteInteraction, + CategoryChannel, + ChannelType, + ChatInputCommandInteraction, + Client, + GatewayIntentBits, + Interaction, + SendableChannels, + SlashCommandBuilder, +} from 'discord.js'; +import type { + AgentChannelInfo, + BridgeProvider, + ChannelTarget, + ConversationRecord, + IncomingMessage, + KernelContext, + MessageTarget, + OutgoingMessage, + ReactionHandle, +} from '../../core/types'; +import { maestro } from '../../core/maestro'; +import { logger } from '../../core/logger'; +import { checkTranscriptionDependencies } from '../../core/transcription'; +import { discordConfig } from './config'; +import { channelDb } from './channelsDb'; +import { threadDb } from './threadsDb'; +import { createMessageCreateHandler } from './messageCreate'; +import { + isVoiceMessage, + isVoiceAttachment, +} from './voice'; +import { transcribeVoiceAttachment, isTranscriberAvailable } from '../../core/transcription'; +import { splitMessage } from '../../core/splitMessage'; +import * as health from './commands/health'; +import * as agents from './commands/agents'; +import * as session from './commands/session'; +import * as playbook from './commands/playbook'; +import * as gist from './commands/gist'; +import * as notes from './commands/notes'; +import * as autoRun from './commands/auto-run'; + +interface CommandModule { + data: { name: string } & Pick; + execute(interaction: ChatInputCommandInteraction): Promise; + autocomplete?(interaction: AutocompleteInteraction): Promise; +} + +const COMMANDS: CommandModule[] = [health, agents, session, playbook, gist, notes, autoRun]; + +export class DiscordProvider implements BridgeProvider { + readonly name = 'discord'; + private client: Client | null = null; + private pendingChannels = new Map>(); + private pendingCategory: Promise | null = null; + + async start(ctx: KernelContext): Promise { + const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + ], + }); + this.client = client; + + const commandsByName = new Map( + COMMANDS.map((c) => [c.data.name, c]), + ); + + client.once('ready', async (c) => { + console.log(`[discord] logged in as ${c.user.tag}`); + await checkTranscriptionDependencies(); + }); + + client.on('interactionCreate', async (interaction: Interaction) => { + const allowed = discordConfig.allowedUserIds; + const isUnauthorized = + allowed.length > 0 && !allowed.includes(interaction.user.id); + + if (interaction.isAutocomplete()) { + if (isUnauthorized) { + await interaction.respond([]); + return; + } + const cmd = commandsByName.get(interaction.commandName); + if (cmd?.autocomplete) { + try { + await cmd.autocomplete(interaction); + } catch (err) { + console.error('Autocomplete error:', err); + } + } + return; + } + + if (!interaction.isChatInputCommand()) return; + if (isUnauthorized) { + await interaction.reply({ + content: '❌ You are not authorized to use this bot.', + ephemeral: true, + }); + return; + } + + const cmd = commandsByName.get(interaction.commandName); + if (!cmd) return; + try { + await cmd.execute(interaction); + } catch (err) { + console.error('Command error:', err); + const msg = { content: '❌ An error occurred.', ephemeral: true }; + if (interaction.replied || interaction.deferred) { + await interaction.followUp(msg); + } else { + await interaction.reply(msg); + } + } + }); + + const handleMessageCreate = createMessageCreateHandler({ + channelDb, + threadDb, + getBotUserId: (message) => message.client.user?.id, + enqueue: ctx.enqueue, + isVoiceMessage, + isVoiceAttachment, + transcribeVoiceAttachment, + isTranscriberAvailable, + splitMessage, + logger: console, + }); + client.on('messageCreate', handleMessageCreate); + + await client.login(discordConfig.token); + } + + async stop(): Promise { + if (this.client) { + this.client.destroy(); + this.client = null; + } + } + + isReady(): boolean { + return !!this.client?.isReady(); + } + + resolveConversation(message: IncomingMessage): ConversationRecord | null { + if (message.isThread) { + const threadInfo = threadDb.get(message.channelId); + if (!threadInfo) return null; + const channelInfo = channelDb.get(threadInfo.channel_id); + if (!channelInfo) return null; + return { + agentId: threadInfo.agent_id, + sessionId: threadInfo.session_id ?? null, + readOnly: !!channelInfo.read_only, + persistSession: (sessionId: string) => threadDb.updateSession(message.channelId, sessionId), + }; + } + + const channelInfo = channelDb.get(message.channelId); + if (!channelInfo) return null; + return { + agentId: channelInfo.agent_id, + sessionId: channelInfo.session_id ?? null, + readOnly: !!channelInfo.read_only, + persistSession: (sessionId: string) => + channelDb.updateSession(message.channelId, sessionId), + }; + } + + async send(target: ChannelTarget, msg: OutgoingMessage): Promise { + const channel = await this.fetchSendable(target.channelId); + let text = msg.text; + if (msg.mention && discordConfig.mentionUserId) { + text = `<@${discordConfig.mentionUserId}> ${text}`; + } + await channel.send(text); + } + + async react(target: MessageTarget, emoji: string): Promise { + const channel = await this.fetchSendable(target.channelId); + const message = await channel.messages.fetch(target.messageId); + const reaction = await message.react(emoji); + const botUserId = this.client?.user?.id; + return { + remove: async () => { + if (botUserId) { + await reaction.users.remove(botUserId); + } else { + await reaction.remove(); + } + }, + }; + } + + async sendTyping(target: ChannelTarget): Promise { + const channel = await this.fetchSendable(target.channelId); + if ('sendTyping' in channel && typeof channel.sendTyping === 'function') { + await channel.sendTyping(); + } + } + + async findOrCreateAgentChannel(agentId: string): Promise { + const existing = channelDb.getByAgentId(agentId); + if (existing) { + return { + channelId: existing.channel_id, + agentId: existing.agent_id, + agentName: existing.agent_name, + }; + } + + const pending = this.pendingChannels.get(agentId); + if (pending) return pending; + + const promise = (async () => { + if (!this.client) throw new Error('Discord client not initialised'); + const allAgents = await maestro.listAgents(); + const agent = allAgents.find((a) => a.id === agentId); + if (!agent) throw new Error(`Agent not found: ${agentId}`); + + const guild = await this.client.guilds.fetch(discordConfig.guildId); + + let category = guild.channels.cache.find( + (c) => c.type === ChannelType.GuildCategory && c.name === 'Maestro Agents', + ); + if (!category) { + if (!this.pendingCategory) { + this.pendingCategory = guild.channels.create({ + name: 'Maestro Agents', + type: ChannelType.GuildCategory, + }); + } + try { + category = await this.pendingCategory; + } finally { + this.pendingCategory = null; + } + } + + const channelName = `agent-${agent.name.toLowerCase().replace(/[^a-z0-9-]/g, '-')}`; + const channel = await guild.channels.create({ + name: channelName, + type: ChannelType.GuildText, + parent: category!.id, + topic: `Maestro agent: ${agent.name} (${agent.id}) | ${agent.toolType} | ${agent.cwd}`, + }); + + channelDb.register(channel.id, guild.id, agent.id, agent.name); + + return { channelId: channel.id, agentId: agent.id, agentName: agent.name }; + })(); + + this.pendingChannels.set(agentId, promise); + try { + return await promise; + } finally { + this.pendingChannels.delete(agentId); + } + } + + private async fetchSendable(channelId: string): Promise { + if (!this.client) throw new Error('Discord client not initialised'); + const fetched = await this.client.channels.fetch(channelId); + if (!fetched?.isSendable()) { + const err = new Error(`Channel ${channelId} is missing or not sendable`); + void logger.error('discord/fetchSendable', err.message); + throw err; + } + return fetched; + } +} diff --git a/src/providers/discord/channelsDb.ts b/src/providers/discord/channelsDb.ts new file mode 100644 index 0000000..22c7fc7 --- /dev/null +++ b/src/providers/discord/channelsDb.ts @@ -0,0 +1,34 @@ +import { channelDb as core, type AgentChannel } from '../../core/db'; + +/** + * Discord-side wrapper around the provider-aware core channel registry. + * Pre-binds `provider='discord'` so adapter code reads naturally. + */ +export const channelDb = { + register(channelId: string, guildId: string, agentId: string, agentName: string): void { + core.register('discord', channelId, agentId, agentName, guildId); + }, + get(channelId: string): AgentChannel | undefined { + return core.get('discord', channelId); + }, + getByAgentId(agentId: string): AgentChannel | undefined { + return core.getByAgentId('discord', agentId); + }, + updateSession(channelId: string, sessionId: string | null): void { + core.updateSession('discord', channelId, sessionId); + }, + setReadOnly(channelId: string, readOnly: boolean): void { + core.setReadOnly('discord', channelId, readOnly); + }, + remove(channelId: string): void { + core.remove('discord', channelId); + }, + listByAgentId(agentId: string): AgentChannel[] { + return core.listByAgentId('discord', agentId); + }, + listByGuild(guildId: string): AgentChannel[] { + return core.listByGuild(guildId); + }, +}; + +export type { AgentChannel } from '../../core/db'; diff --git a/src/commands/agents.ts b/src/providers/discord/commands/agents.ts similarity index 96% rename from src/commands/agents.ts rename to src/providers/discord/commands/agents.ts index 0ba3e0b..2814d0c 100644 --- a/src/commands/agents.ts +++ b/src/providers/discord/commands/agents.ts @@ -5,17 +5,18 @@ import { EmbedBuilder, ChannelType, } from 'discord.js'; -import { maestro } from '../services/maestro'; -import { channelDb, threadDb } from '../db'; -import { cleanupAgentFiles } from '../utils/attachments'; -import { clampFieldValue, clampTitle } from '../utils/embed'; -import { config } from '../config'; +import { maestro } from '../../../core/maestro'; +import { channelDb } from '../channelsDb'; +import { threadDb } from '../threadsDb'; +import { cleanupAgentFiles } from '../../../core/attachments'; +import { clampFieldValue, clampTitle } from '../embed'; +import { discordConfig } from '../config'; function missingBotScopeMessage(): string { return ( '❌ The bot is not a member of this server. It was likely invited with only slash-command permissions.\n\n' + 'Re-invite with both `bot` and `applications.commands` scopes:\n' + - `https://discord.com/oauth2/authorize?client_id=${config.clientId}&scope=bot+applications.commands&permissions=11344` + `https://discord.com/oauth2/authorize?client_id=${discordConfig.clientId}&scope=bot+applications.commands&permissions=11344` ); } diff --git a/src/commands/auto-run.ts b/src/providers/discord/commands/auto-run.ts similarity index 97% rename from src/commands/auto-run.ts rename to src/providers/discord/commands/auto-run.ts index 1d5d65f..6532e2d 100644 --- a/src/commands/auto-run.ts +++ b/src/providers/discord/commands/auto-run.ts @@ -5,8 +5,8 @@ import { ChatInputCommandInteraction, SlashCommandBuilder, } from 'discord.js'; -import { channelDb } from '../db'; -import { maestro } from '../services/maestro'; +import { channelDb } from '../channelsDb'; +import { maestro } from '../../../core/maestro'; export const data = new SlashCommandBuilder() .setName('auto-run') diff --git a/src/commands/gist.ts b/src/providers/discord/commands/gist.ts similarity index 94% rename from src/commands/gist.ts rename to src/providers/discord/commands/gist.ts index 08d5eaf..f809303 100644 --- a/src/commands/gist.ts +++ b/src/providers/discord/commands/gist.ts @@ -3,8 +3,8 @@ import { EmbedBuilder, SlashCommandBuilder, } from 'discord.js'; -import { channelDb } from '../db'; -import { maestro } from '../services/maestro'; +import { channelDb } from '../channelsDb'; +import { maestro } from '../../../core/maestro'; export const data = new SlashCommandBuilder() .setName('gist') diff --git a/src/commands/health.ts b/src/providers/discord/commands/health.ts similarity index 95% rename from src/commands/health.ts rename to src/providers/discord/commands/health.ts index c892da3..a926202 100644 --- a/src/commands/health.ts +++ b/src/providers/discord/commands/health.ts @@ -1,5 +1,5 @@ import { ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js'; -import { maestro } from '../services/maestro'; +import { maestro } from '../../../core/maestro'; export const data = new SlashCommandBuilder() .setName('health') diff --git a/src/commands/notes.ts b/src/providers/discord/commands/notes.ts similarity index 98% rename from src/commands/notes.ts rename to src/providers/discord/commands/notes.ts index 70426a8..8587ae7 100644 --- a/src/commands/notes.ts +++ b/src/providers/discord/commands/notes.ts @@ -3,7 +3,7 @@ import { EmbedBuilder, SlashCommandBuilder, } from 'discord.js'; -import { maestro } from '../services/maestro'; +import { maestro } from '../../../core/maestro'; export const data = new SlashCommandBuilder() .setName('notes') diff --git a/src/commands/playbook.ts b/src/providers/discord/commands/playbook.ts similarity index 99% rename from src/commands/playbook.ts rename to src/providers/discord/commands/playbook.ts index 6e7cc8f..124bef7 100644 --- a/src/commands/playbook.ts +++ b/src/providers/discord/commands/playbook.ts @@ -4,8 +4,8 @@ import { EmbedBuilder, SlashCommandBuilder, } from 'discord.js'; -import { maestro } from '../services/maestro'; -import { clampDescription, clampFieldValue, clampTitle } from '../utils/embed'; +import { maestro } from '../../../core/maestro'; +import { clampDescription, clampFieldValue, clampTitle } from '../embed'; export const data = new SlashCommandBuilder() .setName('playbook') diff --git a/src/commands/session.ts b/src/providers/discord/commands/session.ts similarity index 96% rename from src/commands/session.ts rename to src/providers/discord/commands/session.ts index 58d4b66..e402605 100644 --- a/src/commands/session.ts +++ b/src/providers/discord/commands/session.ts @@ -5,8 +5,9 @@ import { TextChannel, ThreadAutoArchiveDuration, } from 'discord.js'; -import { AgentChannel, channelDb, threadDb } from '../db'; -import { maestro, MaestroSession } from '../services/maestro'; +import { AgentChannel, channelDb } from '../channelsDb'; +import { threadDb } from '../threadsDb'; +import { maestro, MaestroSession } from '../../../core/maestro'; export const data = new SlashCommandBuilder() .setName('session') diff --git a/src/providers/discord/config.ts b/src/providers/discord/config.ts new file mode 100644 index 0000000..8f48364 --- /dev/null +++ b/src/providers/discord/config.ts @@ -0,0 +1,33 @@ +import { required } from '../../core/config'; + +function csv(key: string): string[] { + const val = process.env[key]; + if (!val) return []; + return val + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + +/** + * Discord adapter configuration. Loaded lazily so a deployment that + * disables Discord (ENABLED_PROVIDERS=slack) does not fail at startup + * for missing DISCORD_BOT_TOKEN. + */ +export const discordConfig = { + get token() { + return required('DISCORD_BOT_TOKEN'); + }, + get clientId() { + return required('DISCORD_CLIENT_ID'); + }, + get guildId() { + return required('DISCORD_GUILD_ID'); + }, + get allowedUserIds() { + return csv('DISCORD_ALLOWED_USER_IDS'); + }, + get mentionUserId() { + return process.env.DISCORD_MENTION_USER_ID || ''; + }, +}; diff --git a/src/deploy-commands.ts b/src/providers/discord/deploy.ts similarity index 73% rename from src/deploy-commands.ts rename to src/providers/discord/deploy.ts index 3ef5441..9a76e7c 100644 --- a/src/deploy-commands.ts +++ b/src/providers/discord/deploy.ts @@ -1,5 +1,5 @@ import { REST, Routes } from 'discord.js'; -import { config } from './config'; +import { discordConfig } from './config'; import * as health from './commands/health'; import * as agents from './commands/agents'; import * as session from './commands/session'; @@ -18,12 +18,13 @@ const commands = [ autoRun.data.toJSON(), ]; -const rest = new REST().setToken(config.token); +const rest = new REST().setToken(discordConfig.token); (async () => { console.log('Deploying slash commands...'); - await rest.put(Routes.applicationGuildCommands(config.clientId, config.guildId), { - body: commands, - }); + await rest.put( + Routes.applicationGuildCommands(discordConfig.clientId, discordConfig.guildId), + { body: commands }, + ); console.log('Done.'); })(); diff --git a/src/utils/embed.ts b/src/providers/discord/embed.ts similarity index 100% rename from src/utils/embed.ts rename to src/providers/discord/embed.ts diff --git a/src/handlers/messageCreate.ts b/src/providers/discord/messageCreate.ts similarity index 69% rename from src/handlers/messageCreate.ts rename to src/providers/discord/messageCreate.ts index a503e31..015aeab 100644 --- a/src/handlers/messageCreate.ts +++ b/src/providers/discord/messageCreate.ts @@ -1,23 +1,26 @@ import { Message, TextChannel, ThreadAutoArchiveDuration } from 'discord.js'; import { escapeMarkdown } from '@discordjs/formatters'; -import { channelDb, threadDb } from '../db'; -import { enqueue } from '../services/queue'; +import type { + EnqueueOptions, + IncomingAttachment, + IncomingMessage, +} from '../../core/types'; import { - isVoiceAttachment, - isVoiceMessage, - transcribeVoiceAttachment, isTranscriberAvailable, -} from '../services/transcription'; -import { splitMessage } from '../utils/splitMessage'; + transcribeVoiceAttachment, +} from '../../core/transcription'; +import { splitMessage } from '../../core/splitMessage'; +import { channelDb } from './channelsDb'; +import { threadDb } from './threadsDb'; +import { discordAttachmentToIncoming, isVoiceAttachment, isVoiceMessage } from './voice'; -type MessageCreateDeps = { +type Enqueue = (msg: IncomingMessage, options?: EnqueueOptions) => void; + +export type MessageCreateDeps = { channelDb: Pick; threadDb: Pick; getBotUserId: (message: Message) => string | undefined; - enqueue: ( - message: Message, - options?: { contentOverride?: string; attachmentsOverride?: Message['attachments'] }, - ) => void; + enqueue: Enqueue; isVoiceMessage: typeof isVoiceMessage; isVoiceAttachment: typeof isVoiceAttachment; transcribeVoiceAttachment: typeof transcribeVoiceAttachment; @@ -26,22 +29,34 @@ type MessageCreateDeps = { logger?: Pick; }; +function toIncoming(message: Message, attachmentSource?: IncomingAttachment[]): IncomingMessage { + const attachments = + attachmentSource ?? [...message.attachments.values()].map(discordAttachmentToIncoming); + return { + provider: 'discord', + messageId: message.id, + channelId: message.channel.id, + authorId: message.author.id, + authorName: + message.member?.displayName ?? message.author.username ?? message.author.id, + content: message.content, + attachments, + isThread: message.channel.isThread(), + raw: message, + }; +} + export function createMessageCreateHandler(deps: MessageCreateDeps) { return async function handleMessageCreate(message: Message): Promise { - // Ignore bots (including self) and DMs if (message.author.bot) return; if (!message.guild) return; - // Ignore empty messages (no text and no attachments) if (!message.content.trim() && message.attachments.size === 0) return; const botUserId = deps.getBotUserId(message); if (!botUserId) { - if (deps.logger?.warn) { - deps.logger.warn('messageCreate: bot user ID missing, skipping message handling'); - } else { - console.warn('messageCreate: bot user ID missing, skipping message handling'); - } + const warn = deps.logger?.warn ?? console.warn; + warn('messageCreate: bot user ID missing, skipping message handling'); return; } @@ -52,7 +67,6 @@ export function createMessageCreateHandler(deps: MessageCreateDeps) { return; } - // Detect both direct user mentions (@bot) and role mentions (@BotRole) const mentionedByUser = message.mentions.users.has(botUserId); const botRoleId = message.guild?.members?.me?.roles.botRole?.id; const mentionedByRole = !!(botRoleId && message.mentions.roles?.has(botRoleId)); @@ -86,15 +100,11 @@ export function createMessageCreateHandler(deps: MessageCreateDeps) { ); await thread.send(`This thread is bound to <@${message.author.id}>.`); - // Forward the triggering message to the agent via the new thread - // Strip the bot mention prefix so the agent gets clean content const mentionPattern = botRoleId ? new RegExp(`<@!?${botUserId}>|<@&${botRoleId}>`, 'g') : new RegExp(`<@!?${botUserId}>`, 'g'); const cleanContent = message.content.replace(mentionPattern, '').trim(); if (cleanContent || message.attachments.size > 0) { - // Re-upload attachments so the thread message has real discord.js - // Attachment objects (sending bare URLs produces attachments.size===0). const files = [...message.attachments.values()].map((a) => ({ attachment: a.url, name: a.name, @@ -103,7 +113,7 @@ export function createMessageCreateHandler(deps: MessageCreateDeps) { content: cleanContent || undefined, files: files.length > 0 ? files : undefined, }); - deps.enqueue(threadMessage); + deps.enqueue(toIncoming(threadMessage)); } } catch (err) { const log = deps.logger?.error ?? console.error; @@ -111,7 +121,7 @@ export function createMessageCreateHandler(deps: MessageCreateDeps) { try { await message.reply('❌ Failed to create a thread for this mention.'); } catch { - // Reply may also fail if permissions are missing + /* reply may fail */ } } return; @@ -123,11 +133,8 @@ export function createMessageCreateHandler(deps: MessageCreateDeps) { const ownerUserId = threadInfo.owner_user_id?.trim(); if (ownerUserId && ownerUserId !== message.author.id) return; - // Only treat the message as a voice message if Discord has tagged it with - // the IsVoiceMessage flag β€” a bare .ogg file upload should flow through - // the normal attachment path, not be transcribed. if (!deps.isVoiceMessage(message)) { - deps.enqueue(message); + deps.enqueue(toIncoming(message)); return; } @@ -135,20 +142,21 @@ export function createMessageCreateHandler(deps: MessageCreateDeps) { deps.isVoiceAttachment(attachment), ); if (voiceAttachments.length === 0) { - deps.enqueue(message); + deps.enqueue(toIncoming(message)); return; } if (!deps.isTranscriberAvailable()) { try { await message.reply({ - content: '⚠️ Voice transcription is currently unavailable (missing ffmpeg, whisper-cli, or model file). Message forwarded without transcription.', + content: + '⚠️ Voice transcription is currently unavailable (missing ffmpeg, whisper-cli, or model file). Message forwarded without transcription.', allowedMentions: { parse: [] }, }); } catch { - // Reply may fail if permissions are missing + /* reply may fail */ } - deps.enqueue(message); + deps.enqueue(toIncoming(message)); return; } @@ -156,13 +164,15 @@ export function createMessageCreateHandler(deps: MessageCreateDeps) { try { reaction = await message.react('🎧'); } catch { - // Reaction may fail if message was deleted or bot lacks perms; continue anyway + /* reaction failure is non-fatal */ } try { const transcriptions: string[] = []; for (const attachment of voiceAttachments) { - const transcription = await deps.transcribeVoiceAttachment(attachment); + const transcription = await deps.transcribeVoiceAttachment( + discordAttachmentToIncoming(attachment), + ); transcriptions.push( voiceAttachments.length === 1 ? transcription @@ -179,55 +189,47 @@ export function createMessageCreateHandler(deps: MessageCreateDeps) { const failedReplies = replyResults.filter((result) => result.status === 'rejected'); if (failedReplies.length > 0) { const logWarn = deps.logger?.warn ?? console.warn; - logWarn(`messageCreate: failed to send ${failedReplies.length} transcription reply part(s)`); + logWarn( + `messageCreate: failed to send ${failedReplies.length} transcription reply part(s)`, + ); } try { await reaction?.users.remove(botUserId); } catch { - // Ignore if already removed or no permission + /* ignore cleanup */ } - const contentOverride = [message.content.trim(), transcriptionText].filter(Boolean).join('\n\n'); - const attachmentsOverride = message.attachments.filter( - (attachment) => !deps.isVoiceAttachment(attachment), - ); - deps.enqueue(message, { + const contentOverride = [message.content.trim(), transcriptionText] + .filter(Boolean) + .join('\n\n'); + const nonVoice = [...message.attachments.values()] + .filter((attachment) => !deps.isVoiceAttachment(attachment)) + .map(discordAttachmentToIncoming); + deps.enqueue(toIncoming(message), { contentOverride, - attachmentsOverride, + attachmentsOverride: nonVoice, }); } catch (err) { try { await reaction?.users.remove(botUserId); } catch { - // Ignore if already removed or no permission + /* ignore cleanup */ } const log = deps.logger?.error ?? console.error; log('messageCreate: failed to transcribe voice message:', err); try { await message.reply({ - content: '❌ Failed to transcribe this voice message. Message forwarded without transcription.', + content: + '❌ Failed to transcribe this voice message. Message forwarded without transcription.', allowedMentions: { parse: [] }, }); } catch (replyErr) { const logErr = deps.logger?.error ?? console.error; logErr('messageCreate: failed to send transcription error reply:', replyErr); } - deps.enqueue(message); + deps.enqueue(toIncoming(message)); } }; } - -export const handleMessageCreate = createMessageCreateHandler({ - channelDb, - threadDb, - getBotUserId: (message) => message.client.user?.id, - enqueue, - isVoiceMessage, - isVoiceAttachment, - transcribeVoiceAttachment, - isTranscriberAvailable, - splitMessage, - logger: console, -}); diff --git a/src/providers/discord/threadsDb.ts b/src/providers/discord/threadsDb.ts new file mode 100644 index 0000000..6884734 --- /dev/null +++ b/src/providers/discord/threadsDb.ts @@ -0,0 +1,54 @@ +import { db } from '../../core/db'; + +export interface DiscordAgentThread { + thread_id: string; + channel_id: string; + agent_id: string; + owner_user_id: string | null; + session_id: string | null; + created_at: number; +} + +export const threadDb = { + register(threadId: string, channelId: string, agentId: string, ownerUserId: string): void { + db.prepare( + `INSERT INTO discord_agent_threads (thread_id, channel_id, agent_id, owner_user_id) + VALUES (?, ?, ?, ?)`, + ).run(threadId, channelId, agentId, ownerUserId); + }, + + get(threadId: string): DiscordAgentThread | undefined { + return db + .prepare('SELECT * FROM discord_agent_threads WHERE thread_id = ?') + .get(threadId) as DiscordAgentThread | undefined; + }, + + updateSession(threadId: string, sessionId: string | null): void { + db.prepare('UPDATE discord_agent_threads SET session_id = ? WHERE thread_id = ?').run( + sessionId, + threadId, + ); + }, + + listByChannel(channelId: string): DiscordAgentThread[] { + return db + .prepare( + 'SELECT * FROM discord_agent_threads WHERE channel_id = ? ORDER BY created_at DESC', + ) + .all(channelId) as DiscordAgentThread[]; + }, + + remove(threadId: string): void { + db.prepare('DELETE FROM discord_agent_threads WHERE thread_id = ?').run(threadId); + }, + + getByAgentId(agentId: string): DiscordAgentThread[] { + return db + .prepare('SELECT * FROM discord_agent_threads WHERE agent_id = ?') + .all(agentId) as DiscordAgentThread[]; + }, + + removeByChannel(channelId: string): void { + db.prepare('DELETE FROM discord_agent_threads WHERE channel_id = ?').run(channelId); + }, +}; diff --git a/src/providers/discord/voice.ts b/src/providers/discord/voice.ts new file mode 100644 index 0000000..476e9d6 --- /dev/null +++ b/src/providers/discord/voice.ts @@ -0,0 +1,23 @@ +import { MessageFlags } from 'discord.js'; +import type { Attachment, Message } from 'discord.js'; +import { isVoiceContentType } from '../../core/transcription'; +import type { IncomingAttachment } from '../../core/types'; + +/** Discord-specific: only treat a message as voice when the IsVoiceMessage flag is set. */ +export function isVoiceMessage(message: Pick): boolean { + return !!message.flags?.has(MessageFlags.IsVoiceMessage); +} + +/** Discord-specific: filter to attachments that look like Discord voice payloads. */ +export function isVoiceAttachment(attachment: Attachment): boolean { + return isVoiceContentType(attachment.contentType ?? undefined, attachment.name); +} + +export function discordAttachmentToIncoming(attachment: Attachment): IncomingAttachment { + return { + url: attachment.url, + name: attachment.name, + size: attachment.size, + contentType: attachment.contentType ?? undefined, + }; +} diff --git a/src/server.ts b/src/server.ts deleted file mode 100644 index 37e15a0..0000000 --- a/src/server.ts +++ /dev/null @@ -1,310 +0,0 @@ -import http from 'http'; -import { Client, ChannelType, CategoryChannel, SendableChannels } from 'discord.js'; - -export interface AgentChannelRecord { - channel_id: string; - guild_id: string; - agent_id: string; - agent_name: string; - session_id: string | null; - read_only: number; - created_at: number; -} - -export interface SendRequest { - agentId: string; - message: string; - mention?: boolean; -} - -export type ServerDeps = { - channelDb: { - getByAgentId(agentId: string): AgentChannelRecord | undefined; - register(channelId: string, guildId: string, agentId: string, agentName: string): void; - }; - maestro: { - listAgents(): Promise>; - }; - splitMessage: (content: string) => string[]; - config: { guildId: string; apiPort: number; mentionUserId: string }; - logger: { error(...args: unknown[]): unknown }; -}; - -const MAX_BODY_SIZE = 1_048_576; // 1 MB - -export function parseBody(req: http.IncomingMessage): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - let size = 0; - - req.on('data', (chunk: Buffer) => { - size += chunk.length; - if (size > MAX_BODY_SIZE) { - req.destroy(); - reject(new Error('Request body too large')); - return; - } - chunks.push(chunk); - }); - - req.on('end', () => { - try { - const body = Buffer.concat(chunks).toString(); - resolve(JSON.parse(body) as SendRequest); - } catch { - reject(new Error('Invalid JSON')); - } - }); - - req.on('error', reject); - }); -} - -function sendJson(res: http.ServerResponse, status: number, data: object) { - res.writeHead(status, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(data)); -} - -export function createServerHandler(client: Client, deps: ServerDeps) { - const pendingChannels = new Map>(); - let pendingCategory: Promise | null = null; - - async function findOrCreateChannel(agentId: string): Promise { - const existing = deps.channelDb.getByAgentId(agentId); - if (existing) return existing; - - // Deduplicate concurrent creation for the same agent - const pending = pendingChannels.get(agentId); - if (pending) return pending; - - const promise = (async () => { - const agents = await deps.maestro.listAgents(); - const agent = agents.find((a) => a.id === agentId); - if (!agent) throw new Error(`Agent not found: ${agentId}`); - - const guild = await client.guilds.fetch(deps.config.guildId); - - // Find or create "Maestro Agents" category (deduplicated across concurrent requests) - let category = guild.channels.cache.find( - (c) => c.type === ChannelType.GuildCategory && c.name === 'Maestro Agents', - ); - if (!category) { - if (!pendingCategory) { - pendingCategory = guild.channels.create({ - name: 'Maestro Agents', - type: ChannelType.GuildCategory, - }); - } - try { - category = await pendingCategory; - } finally { - pendingCategory = null; - } - } - - const channelName = `agent-${agent.name.toLowerCase().replace(/[^a-z0-9-]/g, '-')}`; - const channel = await guild.channels.create({ - name: channelName, - type: ChannelType.GuildText, - parent: category!.id, - topic: `Maestro agent: ${agent.name} (${agent.id}) | ${agent.toolType} | ${agent.cwd}`, - }); - - deps.channelDb.register(channel.id, guild.id, agent.id, agent.name); - - return deps.channelDb.getByAgentId(agentId)!; - })(); - - pendingChannels.set(agentId, promise); - try { - return await promise; - } finally { - pendingChannels.delete(agentId); - } - } - - async function handleSend(req: http.IncomingMessage, res: http.ServerResponse) { - // Client readiness check - if (!client.isReady()) { - await deps.logger.error('api', 'Bot is not connected to Discord'); - sendJson(res, 503, { success: false, error: 'Bot is not connected to Discord' }); - return; - } - - // Validate Content-Type - const contentType = req.headers['content-type'] || ''; - if (!contentType.includes('application/json')) { - sendJson(res, 415, { success: false, error: 'Content-Type must be application/json' }); - return; - } - - // Parse body - let body: SendRequest; - try { - body = await parseBody(req); - } catch (err) { - const message = (err as Error).message; - const status = message === 'Request body too large' ? 413 : 400; - sendJson(res, status, { success: false, error: message }); - return; - } - - // Validate required fields - if ( - !body || - typeof body !== 'object' || - Array.isArray(body) || - typeof body.agentId !== 'string' || - body.agentId.trim() === '' || - typeof body.message !== 'string' || - body.message.trim() === '' - ) { - sendJson(res, 400, { - success: false, - error: 'agentId and message are required non-empty strings', - }); - return; - } - - // Find or create channel - let record; - try { - record = await findOrCreateChannel(body.agentId); - } catch (err) { - const msg = (err as Error).message; - if (msg.startsWith('Agent not found:')) { - sendJson(res, 404, { success: false, error: msg }); - } else { - await deps.logger.error('server/findOrCreateChannel', msg); - sendJson(res, 500, { success: false, error: msg }); - } - return; - } - - // Fetch Discord channel - let channel: SendableChannels; - try { - const fetched = await client.channels.fetch(record.channel_id); - if (!fetched?.isSendable()) { - throw new Error(`Configured channel ${record.channel_id} is missing or not sendable`); - } - channel = fetched; - } catch (err) { - const msg = `Failed to fetch channel ${record.channel_id}: ${(err as Error).message}`; - await deps.logger.error('server/fetchChannel', msg); - sendJson(res, 500, { success: false, error: msg }); - return; - } - - // Build message content - let content = body.message; - if (body.mention && deps.config.mentionUserId) { - content = `<@${deps.config.mentionUserId}> ${content}`; - } - - const parts = deps.splitMessage(content); - - // Send each part with retry for rate limits - for (const part of parts) { - let lastError: Error | undefined; - for (let attempt = 0; attempt < 3; attempt++) { - try { - await channel.send(part); - lastError = undefined; - break; - } catch (err) { - lastError = err as Error; - const discordErr = err as { status?: number; retryAfter?: number }; - const isRateLimited = discordErr.status === 429 || discordErr.retryAfter != null; - if (isRateLimited) { - const delay = discordErr.retryAfter ?? 1000; - await new Promise((r) => setTimeout(r, delay)); - } else { - break; // non-rate-limit error, don't retry - } - } - } - if (lastError) { - const discordErr = lastError as Error & { status?: number; retryAfter?: number }; - const isRateLimited = discordErr.status === 429 || discordErr.retryAfter != null; - if (isRateLimited) { - await deps.logger.error('api', 'Rate limited by Discord after 3 retries'); - sendJson(res, 429, { success: false, error: 'Rate limited by Discord, retry later' }); - } else { - await deps.logger.error('api', lastError.message); - sendJson(res, 500, { success: false, error: lastError.message }); - } - return; - } - } - - sendJson(res, 200, { success: true, channelId: record.channel_id }); - } - - return function handler(req: http.IncomingMessage, res: http.ServerResponse) { - const url = req.url || ''; - - if (url === '/api/health') { - if (req.method !== 'GET') { - sendJson(res, 405, { success: false, error: 'Method not allowed' }); - return; - } - const ready = client.isReady(); - sendJson(res, ready ? 200 : 503, { - success: ready, - status: ready ? 'ok' : 'not_ready', - uptime: process.uptime(), - }); - return; - } - - if (url === '/api/send') { - if (req.method !== 'POST') { - sendJson(res, 405, { success: false, error: 'Method not allowed' }); - return; - } - handleSend(req, res).catch(async (err) => { - const msg = (err as Error).message || 'Internal server error'; - await deps.logger.error('server/unhandled', msg); - sendJson(res, 500, { success: false, error: msg }); - }); - return; - } - - sendJson(res, 404, { success: false, error: 'Not found' }); - }; -} - -export function startServer(client: Client): http.Server { - // Lazy imports to avoid pulling in native deps at module scope (testability) - const { channelDb } = require('./db') as typeof import('./db'); - const { maestro } = require('./services/maestro') as typeof import('./services/maestro'); - const { splitMessage } = require('./utils/splitMessage') as typeof import('./utils/splitMessage'); - const { config } = require('./config') as typeof import('./config'); - const { logger } = require('./services/logger') as typeof import('./services/logger'); - - const handler = createServerHandler(client, { - channelDb, - maestro, - splitMessage, - config, - logger, - }); - - const server = http.createServer(handler); - - server.on('error', (err: NodeJS.ErrnoException) => { - if (err.code === 'EADDRINUSE') { - console.error(`API server failed to start: port ${config.apiPort} is already in use`); - } else { - console.error('API server error:', err.message); - } - process.exit(1); - }); - - server.listen(config.apiPort, '127.0.0.1', () => { - console.log(`API server listening on http://127.0.0.1:${config.apiPort}`); - }); - - return server; -} diff --git a/src/services/.gitkeep b/src/services/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/services/queue.ts b/src/services/queue.ts deleted file mode 100644 index c0d1672..0000000 --- a/src/services/queue.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { maestro } from './maestro'; -import { channelDb, threadDb } from '../db'; -import { splitMessage } from '../utils/splitMessage'; -import { downloadAttachments, formatAttachmentRefs } from '../utils/attachments'; -import { logger } from './logger'; -import { createQueue } from './queueFactory'; - -export { createQueue } from './queueFactory'; -export type { QueueDeps } from './queueFactory'; - -const defaultQueue = createQueue({ - maestro, - channelDb, - threadDb, - splitMessage, - downloadAttachments, - formatAttachmentRefs, - logger, -}); - -export const enqueue = defaultQueue.enqueue; diff --git a/src/services/queueFactory.ts b/src/services/queueFactory.ts deleted file mode 100644 index 841f75f..0000000 --- a/src/services/queueFactory.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { Message, TextChannel, ThreadChannel } from 'discord.js'; - -interface QueueEntry { - message: Message; - options?: EnqueueOptions; -} - -export type EnqueueOptions = { - contentOverride?: string; - attachmentsOverride?: Message['attachments']; -}; - -export type QueueDeps = { - maestro: { - getAgentCwd: (agentId: string) => Promise; - send: ( - agentId: string, - message: string, - sessionId?: string, - readOnly?: boolean, - ) => Promise<{ - success: boolean; - response: string | null; - error?: string; - sessionId?: string; - usage?: { - inputTokens?: number; - outputTokens?: number; - totalCostUsd?: number; - contextUsagePercent?: number; - }; - }>; - }; - channelDb: { - get: (channelId: string) => - | { - channel_id: string; - agent_id: string; - session_id?: string | null; - read_only?: number | boolean; - } - | undefined; - updateSession: (channelId: string, sessionId: string) => void; - }; - threadDb: { - get: (threadId: string) => - | { - thread_id: string; - channel_id: string; - agent_id: string; - session_id?: string | null; - owner_user_id?: string | null; - } - | undefined; - updateSession: (threadId: string, sessionId: string) => void; - }; - splitMessage: (text: string) => string[]; - downloadAttachments: ( - attachments: Message['attachments'], - agentCwd: string, - ) => Promise<{ downloaded: { originalName: string; savedPath: string }[]; failed: string[] }>; - formatAttachmentRefs: (files: { originalName: string; savedPath: string }[]) => string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - logger: { error: (...args: any[]) => any }; -}; - -export function createQueue(deps: QueueDeps) { - const queues = new Map(); - const processing = new Set(); - - function enqueue(message: Message, options?: EnqueueOptions): void { - const channelId = message.channel.id; - if (!queues.has(channelId)) queues.set(channelId, []); - queues.get(channelId)!.push({ message, options }); - - if (!processing.has(channelId)) { - void processNext(channelId); - } - } - - async function processNext(channelId: string): Promise { - const queue = queues.get(channelId); - if (!queue || queue.length === 0) { - processing.delete(channelId); - return; - } - - processing.add(channelId); - const { message, options } = queue.shift()!; - - const isThread = message.channel.isThread(); - const threadInfo = isThread ? deps.threadDb.get(channelId) : undefined; - const channelInfo = threadInfo - ? deps.channelDb.get(threadInfo.channel_id) - : deps.channelDb.get(channelId); - - if (!channelInfo) { - processing.delete(channelId); - return; - } - - const agentId = threadInfo ? threadInfo.agent_id : channelInfo.agent_id; - const sessionId = threadInfo - ? (threadInfo.session_id ?? undefined) - : (channelInfo.session_id ?? undefined); - - const channel = message.channel as TextChannel | ThreadChannel; - - let reaction: Awaited> | undefined; - try { - reaction = await message.react('⏳'); - } catch { - // Reaction may fail if message was deleted or bot lacks perms; continue anyway - } - - const typingInterval = setInterval(() => { - channel.sendTyping().catch(() => {}); - }, 8000); - channel.sendTyping().catch(() => {}); - - try { - // Download attachments if present - let attachmentRefs = ''; - const attachmentsToProcess = options?.attachmentsOverride ?? message.attachments; - if (attachmentsToProcess.size > 0) { - try { - const agentCwd = await deps.maestro.getAgentCwd(agentId); - if (agentCwd) { - const result = await deps.downloadAttachments(attachmentsToProcess, agentCwd); - attachmentRefs = deps.formatAttachmentRefs(result.downloaded); - if (result.failed.length > 0) { - await channel.send( - `⚠️ Failed to download: ${result.failed.join(', ')}. Sending message without those files.`, - ); - } - } else { - await channel.send('⚠️ Could not resolve agent working directory for file downloads.'); - } - } catch (err) { - const errMsg = err instanceof Error ? err.message : String(err); - void deps.logger.error( - 'queue:attachment-download', - `agent=${agentId} channel=${channelId} error=${errMsg}`, - ); - await channel.send('⚠️ Failed to download attachments. Sending message without them.'); - } - } - - const readOnly = !!channelInfo.read_only; - const fullMessage = [options?.contentOverride ?? message.content, attachmentRefs] - .filter(Boolean) - .join('\n\n'); - const result = await deps.maestro.send(agentId, fullMessage, sessionId, readOnly); - - // Persist session ID from first response - if (!sessionId && result.sessionId) { - if (threadInfo) { - deps.threadDb.updateSession(channelId, result.sessionId); - } else { - deps.channelDb.updateSession(channelId, result.sessionId); - } - } - - clearInterval(typingInterval); - - try { - await reaction?.remove(); - } catch { - // Ignore if already removed or no permission - } - - if (!result.success || !result.response) { - const reason = result.error ?? 'The agent could not complete this request.'; - const hint = readOnly - ? '\n-# The agent is in **read-only** mode and cannot modify files.' - : ''; - void deps.logger.error( - 'queue:agent-failure', - `agent=${agentId} session=${sessionId ?? 'new'} channel=${channelId} reason=${reason}`, - ); - await channel.send(`⚠️ ${reason}${hint}`); - } else { - const parts = deps.splitMessage(result.response); - for (const part of parts) { - await channel.send(part); - } - } - - const cost = (result.usage?.totalCostUsd ?? 0).toFixed(4); - const ctx = (result.usage?.contextUsagePercent ?? 0).toFixed(1); - const tokens = (result.usage?.inputTokens ?? 0) + (result.usage?.outputTokens ?? 0); - await channel.send( - `-# πŸ’¬ ${tokens} tokens β€’ $${cost} β€’ ${ctx}% context${readOnly ? ' β€’ πŸ“– read-only' : ''}`, - ); - } catch (err) { - clearInterval(typingInterval); - try { - await reaction?.remove(); - } catch { - /* reaction cleanup is best-effort */ - } - - const errMsg = err instanceof Error ? err.message : String(err); - void deps.logger.error( - 'queue:send-error', - `agent=${agentId} session=${sessionId ?? 'new'} channel=${channelId} error=${errMsg}`, - ); - await channel.send(`❌ Failed to get response from agent:\n\`\`\`\n${errMsg}\n\`\`\``); - } - - void processNext(channelId); - } - - return { enqueue }; -} diff --git a/src/utils/splitMessage.ts b/src/utils/splitMessage.ts deleted file mode 100644 index ec0b4ac..0000000 --- a/src/utils/splitMessage.ts +++ /dev/null @@ -1,24 +0,0 @@ -const MAX_LENGTH = 1990; // small buffer below 2000 - -/** - * Split a string into chunks that fit within Discord's message length limit. - * Tries to split on newlines when possible to preserve formatting. - */ -export function splitMessage(text: string): string[] { - if (text.length <= MAX_LENGTH) return [text]; - - const parts: string[] = []; - let remaining = text; - - while (remaining.length > MAX_LENGTH) { - // Try to find a newline to break on within the limit - let splitAt = remaining.lastIndexOf('\n', MAX_LENGTH); - if (splitAt <= 0) splitAt = MAX_LENGTH; // fallback: hard cut - - parts.push(remaining.slice(0, splitAt)); - remaining = remaining.slice(splitAt).trimStart(); - } - - if (remaining.length > 0) parts.push(remaining); - return parts; -} From bce53e196d85da648ff004a606c779c7d84f6907 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Tue, 5 May 2026 11:24:25 +0200 Subject: [PATCH 07/31] chore(rename): discord-maestro -> maestro-bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the project to reflect the new architecture: a provider-agnostic bridge between chat platforms and Maestro agents, with Discord as the first provider. Local changes only β€” does NOT rename the GitHub repo or migrate the installer in RunMaestro/Maestro-Discord; both require manual follow-up. Renames: - package name: discord-maestro -> maestro-bridge - bin: maestro-bridge (primary), maestro-discord kept as alias pointing at the same dist/cli/maestro-bridge.js - src/cli/maestro-discord.ts -> src/cli/maestro-bridge.ts - npm script: maestro-bridge (alias maestro-discord retained) User-facing strings in CLI help, README, AGENTS.md, docs/api.md, and docs/architecture.md updated to "Maestro Bridge". All DISCORD_* env vars unchanged. .env.example reorganised into Core / Discord sections with ENABLED_PROVIDERS=discord (default) added. Migration: existing scripts calling `maestro-discord ...` keep working unchanged via the alias bin. Tests, typecheck, and lint all green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 18 ++-- AGENTS.md | 69 ++++++++++---- README.md | 87 +++++++++++------- docs/api.md | 88 ++++++++++++------ docs/architecture.md | 91 ++++++++++++++----- package.json | 10 +- .../{maestro-discord.ts => maestro-bridge.ts} | 8 +- src/cli/verbs/notify.ts | 4 +- src/cli/verbs/send.ts | 4 +- src/cli/verbs/status.ts | 4 +- 10 files changed, 262 insertions(+), 121 deletions(-) rename src/cli/{maestro-discord.ts => maestro-bridge.ts} (82%) diff --git a/.env.example b/.env.example index 8e8ff91..7715bb5 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,14 @@ +# --- Core (provider-neutral) --- +ENABLED_PROVIDERS=discord # comma-separated list of bridge providers to enable (default: discord) +API_PORT=3457 # optional: port for the internal HTTP API used by maestro-bridge CLI +FFMPEG_PATH=ffmpeg # optional: override ffmpeg executable path +WHISPER_CLI_PATH=whisper-cli # optional: override whisper-cli executable path +# mkdir -p ./models && curl -L -o models/ggml-base.en.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin +WHISPER_MODEL_PATH=models/ggml-base.en.bin # optional: whisper.cpp model path + +# --- Discord provider (loaded only if 'discord' is in ENABLED_PROVIDERS) --- DISCORD_BOT_TOKEN=your_bot_token_here DISCORD_CLIENT_ID=your_application_client_id_here DISCORD_GUILD_ID=your_guild_id_here -DISCORD_ALLOWED_USER_IDS=123,456 # comma-separated Discord user IDs -API_PORT=3457 # optional: port for internal API (maestro-discord CLI) -DISCORD_MENTION_USER_ID= # optional: Discord user ID to @mention when --mention is used -FFMPEG_PATH=ffmpeg # optional: override ffmpeg executable path -WHISPER_CLI_PATH=whisper-cli # optional: override whisper-cli executable path -# mkdir -p ./models && curl -L -o models/ggml-base.en.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin -WHISPER_MODEL_PATH=models/ggml-base.en.bin # optional: whisper.cpp model path +DISCORD_ALLOWED_USER_IDS=123,456 # comma-separated Discord user IDs allowed to use slash commands +DISCORD_MENTION_USER_ID= # optional: Discord user ID to @mention when --mention is used diff --git a/AGENTS.md b/AGENTS.md index 44cf784..165a75f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,46 +1,77 @@ # Agent Guide -This repo is a Discord bot that bridges messages to Maestro agents via `maestro-cli`. -CLAUDE.md is a symlink to this file. +This repo is **Maestro Bridge** β€” a chat-platform-to-Maestro bridge built around a provider-agnostic kernel. Discord is the first provider; Slack/Teams plug in alongside it without touching the kernel. CLAUDE.md is a symlink to this file. ## Development workflow - Install deps: `npm install` - Run in dev: `npm run dev` -- Deploy slash commands: `npm run deploy-commands` +- Deploy slash commands (Discord): `npm run deploy-commands` - Build: `npm run build` - Production: `npm run build` then `npm start` - Run tests: `npm test` ## Project layout -- `src/config.ts` β€” env var loading -- `src/db/index.ts` β€” SQLite channel registry (agent_channels table) -- `src/services/maestro.ts` β€” maestro-cli wrapper (listAgents, listSessions, send) -- `src/services/queue.ts` β€” per-channel FIFO message queue -- `src/services/logger.ts` β€” logging service -- `src/server.ts` β€” internal HTTP API server (POST /api/send, GET /api/health) -- `src/commands/` β€” slash command handlers (health, agents) -- `src/handlers/messageCreate.ts` β€” Discord message listener β†’ queue -- `src/utils/splitMessage.ts` β€” splits long messages for Discord's 2000-char limit -- `src/deploy-commands.ts` β€” registers slash commands with Discord API -- `bin/maestro-discord.ts` β€” CLI tool for agent-to-Discord messaging +### Core (provider-agnostic kernel) + +- `src/core/types.ts` β€” `BridgeProvider`, `IncomingMessage`, `ConversationRecord`, `ChannelTarget`, `OutgoingMessage`, `KernelContext` +- `src/core/queue.ts` β€” per-conversation FIFO message queue, typed on `IncomingMessage` +- `src/core/api.ts` β€” internal HTTP API server (`POST /api/send`, `GET /api/health`) +- `src/core/providers.ts` β€” provider registry (loads adapters by name from `ENABLED_PROVIDERS`) +- `src/core/db/index.ts` β€” SQLite registry with composite PK `(provider, channel_id)` +- `src/core/db/migrations.ts` β€” idempotent schema upgrades +- `src/core/maestro.ts` β€” `maestro-cli` wrapper +- `src/core/transcription.ts` β€” generic ffmpeg + whisper pipeline +- `src/core/attachments.ts` β€” provider-agnostic attachment download +- `src/core/logger.ts`, `src/core/config.ts`, `src/core/splitMessage.ts` + +### Discord provider + +- `src/providers/discord/adapter.ts` β€” implements `BridgeProvider` +- `src/providers/discord/messageCreate.ts` β€” Discord message β†’ `IncomingMessage` +- `src/providers/discord/voice.ts` β€” Discord voice-message detection +- `src/providers/discord/commands/` β€” slash command handlers +- `src/providers/discord/deploy.ts` β€” registers slash commands with Discord API +- `src/providers/discord/channelsDb.ts` β€” `provider='discord'`-bound wrapper around the core channel registry +- `src/providers/discord/threadsDb.ts` β€” Discord-only thread registry (`discord_agent_threads`) +- `src/providers/discord/embed.ts` β€” Discord embed limit helpers +- `src/providers/discord/config.ts` β€” `DISCORD_*` env loading + +### CLI + +- `src/cli/maestro-bridge.ts` β€” verb dispatcher (`send`, `notify`, `status`) +- `src/cli/lib.ts` β€” shared HTTP client for `/api/send` +- `src/cli/verbs/` β€” individual verb implementations + +The `maestro-discord` binary is registered as an alias of `maestro-bridge` for back-compat. + +### Entry point + +- `src/index.ts` β€” kernel orchestrator: builds providers, starts each with kernel ctx, starts the HTTP API, wires graceful shutdown ## HTTP API Local API on `127.0.0.1:API_PORT` (default 3457). See [docs/api.md](docs/api.md) for endpoints, request format, and error codes. +## Adding a new provider + +1. Create `src/providers//adapter.ts` exporting a class that implements `BridgeProvider` from `src/core/types.ts`. +2. Register the provider name in `src/core/providers.ts` (`loadProvider` switch). +3. Add a section to `.env.example` for the provider's credentials. +4. Provider modules own their own DB tables, command surface, and event handling; the kernel only sees `IncomingMessage` and calls back via `BridgeProvider.send` / `react` / `sendTyping`. + ## Project notes - Source lives in `src/` and is TypeScript. -- Env vars are defined in `.env.example`. Keep it in sync with `.env` usage. -- Avoid adding new runtime dependencies unless necessary. -- If you add new slash commands, update the deploy script and README. +- Env vars are documented in `.env.example`. Keep it in sync with `.env` usage. +- `ENABLED_PROVIDERS` is a comma-separated list (default `discord`); each provider validates its own creds at `start()`, so a disabled provider doesn't fail the bridge on missing env. - Tests use Node.js built-in test runner (`node --test`), not Jest/Vitest. -- The server uses `isSendable()` type guard for channel safety (not unsafe casts). +- The Discord adapter uses `isSendable()` type guards for channel safety. ## Expectations for changes -- Follow existing patterns in `src/` before introducing new abstractions. +- Follow existing patterns in `src/core/` and `src/providers/discord/` before introducing new abstractions. +- Provider-specific code (Discord types, slash commands, threads) lives in `src/providers/discord/` β€” keep `src/core/` free of `discord.js` imports. - Keep changes minimal and focused. - Update docs when behavior or setup changes. diff --git a/README.md b/README.md index 4f9a72c..859d345 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,36 @@ -# Discord Maestro Bot +# Maestro Bridge [![Made with Maestro](https://raw.githubusercontent.com/RunMaestro/Maestro/main/docs/assets/made-with-maestro.svg)](https://github.com/RunMaestro/Maestro) -A Discord bot that connects your server to [Maestro](https://runmaestro.ai) AI agents through `maestro-cli`. +**Maestro Bridge** connects chat platforms to [Maestro](https://runmaestro.ai) AI agents through `maestro-cli`. Discord ships in the box; Slack, Teams, and others can be added by dropping in a provider adapter β€” the kernel is provider-agnostic. + +> **Migrating from `discord-maestro`?** Same codebase, new name. The legacy `maestro-discord` binary is preserved as an alias and all `DISCORD_*` env vars work unchanged. See "Migration" below. ## Features -- Creates dedicated Discord channels for Maestro agents -- Per-user session threads β€” start one with `/session new` or by @mentioning the bot in an agent channel -- Queues messages per channel for orderly processing -- Streams agent replies back into Discord, including usage stats +- Provider-pluggable kernel β€” Discord today, Slack/Teams next +- Creates dedicated channels for Maestro agents +- Per-user session threads (`/session new` or by mentioning the bot) +- Per-conversation FIFO queue with typing/reaction indicators +- Streams agent replies back into chat with usage stats +- Voice transcription pipeline (whisper.cpp) for Discord voice messages ## Prerequisites - Node.js 18+ -- A Discord application + bot token -- [Maestro CLI](https://docs.runmaestro.ai/cli) available on your `PATH` (no authentication required) +- A Discord application + bot token (if running the Discord provider) +- [Maestro CLI](https://docs.runmaestro.ai/cli) on your `PATH` -### Install maestro-discord CLI +### Install the `maestro-bridge` CLI -The `maestro-discord` CLI lets your Maestro agents reach out to you on Discord β€” for example, to ping you when a long-running task finishes. See [docs/api.md](docs/api.md) for usage. +The `maestro-bridge` CLI lets your Maestro agents reach out to chat β€” for example, to ping you when a long-running task finishes. See [docs/api.md](docs/api.md) for usage. -After building the project (`npm run build`), create a shell wrapper. +After building (`npm run build`), create a shell wrapper. macOS / Linux: ```bash -printf '#!/bin/bash\nnode "%s/dist/cli/maestro-discord.js" "$@"\n' "$(pwd)" | sudo tee /usr/local/bin/maestro-discord && sudo chmod +x /usr/local/bin/maestro-discord +printf '#!/bin/bash\nnode "%s/dist/cli/maestro-bridge.js" "$@"\n' "$(pwd)" | sudo tee /usr/local/bin/maestro-bridge && sudo chmod +x /usr/local/bin/maestro-bridge ``` Windows (PowerShell) β€” writes the wrapper to `%USERPROFILE%\bin` and adds it to your user `PATH`: @@ -37,8 +41,8 @@ $binDir = "$env:USERPROFILE\bin" New-Item -ItemType Directory -Force -Path $binDir | Out-Null @" @echo off -node "$repoPath\dist\cli\maestro-discord.js" %* -"@ | Out-File -FilePath "$binDir\maestro-discord.cmd" -Encoding ASCII +node "$repoPath\dist\cli\maestro-bridge.js" %* +"@ | Out-File -FilePath "$binDir\maestro-bridge.cmd" -Encoding ASCII # Add $binDir to user PATH if it isn't already (restart your shell afterwards) $userPath = [Environment]::GetEnvironmentVariable('PATH', 'User') @@ -53,6 +57,8 @@ Or use `npm link`: npm link ``` +The legacy `maestro-discord` binary is registered as an alias to the same JS, so existing scripts keep working. + ## Quick start 1. Install dependencies: @@ -70,24 +76,30 @@ cp .env.example .env Set these values in `.env`: ``` -DISCORD_BOT_TOKEN= # Bot token from Discord Developer Portal -DISCORD_CLIENT_ID= # Application ID from Discord Developer Portal -DISCORD_GUILD_ID= # Your server's ID (right-click server β†’ Copy ID) -DISCORD_ALLOWED_USER_IDS=123,456 # Optional: comma-separated user IDs allowed to run slash commands -API_PORT=3457 # Optional: port for internal API (default 3457) -DISCORD_MENTION_USER_ID= # Optional: Discord user ID to @mention when --mention is used -FFMPEG_PATH=/opt/homebrew/bin/ffmpeg # Optional: path to ffmpeg binary -WHISPER_CLI_PATH=/opt/homebrew/bin/whisper-cli # Optional: path to whisper-cli binary -WHISPER_MODEL_PATH=models/ggml-base.en.bin # Optional: path to whisper.cpp model +# Core +ENABLED_PROVIDERS=discord # comma-separated; default 'discord' +API_PORT=3457 # optional, default 3457 + +# Discord provider +DISCORD_BOT_TOKEN= # Bot token from Discord Developer Portal +DISCORD_CLIENT_ID= # Application ID from Discord Developer Portal +DISCORD_GUILD_ID= # Your server's ID (right-click server β†’ Copy ID) +DISCORD_ALLOWED_USER_IDS=123,456 # Optional: comma-separated allowed user IDs +DISCORD_MENTION_USER_ID= # Optional: user ID to @mention when --mention is used + +# Voice transcription (optional) +FFMPEG_PATH=/opt/homebrew/bin/ffmpeg +WHISPER_CLI_PATH=/opt/homebrew/bin/whisper-cli +WHISPER_MODEL_PATH=models/ggml-base.en.bin ``` -3. Deploy slash commands: +3. Deploy slash commands (Discord): ```bash npm run deploy-commands ``` -4. Start the bot (dev mode): +4. Start the bridge (dev mode): ```bash npm run dev @@ -95,9 +107,9 @@ npm run dev ## Voice Transcription (optional) -When a user posts a Discord **voice message** (the mic-button recording, not an arbitrary `.ogg` upload) in a session thread, the bot transcribes the audio with `whisper.cpp` and forwards the transcript to the agent. The original `.ogg` is **not** sent to the agent β€” only the transcribed text β€” and a `🎧` reaction marks the message while transcription runs. +When a user posts a Discord **voice message** (the mic-button recording, not an arbitrary `.ogg` upload) in a session thread, the bridge transcribes the audio with `whisper.cpp` and forwards the transcript to the agent. The original `.ogg` is **not** sent to the agent β€” only the transcribed text β€” and a `🎧` reaction marks the message while transcription runs. -If the dependencies below are missing, the bot starts normally and voice messages are forwarded as plain attachments with a one-line advisory; no other functionality is affected. +If the dependencies below are missing, the bridge starts normally and voice messages are forwarded as plain attachments with a one-line advisory; no other functionality is affected. **Behavior notes:** @@ -130,7 +142,7 @@ WHISPER_CLI_PATH=/opt/homebrew/bin/whisper-cli WHISPER_MODEL_PATH=models/ggml-base.en.bin ``` -The bot probes these at startup; any missing piece is logged as `⚠️ Transcription disabled: …` and transcription is skipped at runtime. +The bridge probes these at startup; any missing piece is logged as `⚠️ Transcription disabled: …` and transcription is skipped at runtime. ## Production run @@ -151,7 +163,7 @@ Coverage: npm run build && node --test --experimental-test-coverage dist/__tests__/**/*.test.js ``` -## Slash commands +## Slash commands (Discord) | Command | Description | | -------------------------- | ------------------------------------------------------------- | @@ -173,15 +185,24 @@ npm run build && node --test --experimental-test-coverage dist/__tests__/**/*.te ## How it works -Mention the bot or run `/session new` in an agent channel to create a thread, then chat β€” messages are queued and forwarded to the agent via `maestro-cli`. See [docs/architecture.md](docs/architecture.md) for the full message flow, thread ownership model, and project layout. +Mention the bot or run `/session new` in an agent channel to create a thread, then chat β€” messages are queued and forwarded to the agent via `maestro-cli`. See [docs/architecture.md](docs/architecture.md) for the full message flow, the kernel/provider split, and how to add a new provider. + +## Agent β†’ chat messaging + +Agents can push messages to chat via the `maestro-bridge` CLI / HTTP API. See [docs/api.md](docs/api.md) for usage, endpoints, and error codes. + +## Migration from `discord-maestro` -## Maestro-to-Discord Messaging +This project was renamed from `discord-maestro` / `Maestro-Discord`. To smooth upgrades: -Agents can push messages to Discord via the `maestro-discord` CLI / HTTP API. See [docs/api.md](docs/api.md) for usage, endpoints, and error codes. +- The `maestro-discord` binary is preserved as an alias of `maestro-bridge`. Existing scripts that call `maestro-discord send …` keep working unchanged. +- All `DISCORD_*` env vars are unchanged. New optional `ENABLED_PROVIDERS` defaults to `discord`. +- The SQLite database upgrades automatically on first start: `agent_channels` gains a `provider` column (existing rows default to `discord`); `agent_threads` is renamed to `discord_agent_threads` with rows preserved. No manual migration needed. +- The HTTP `/api/send` endpoint accepts an optional `provider` field that defaults to `discord`; existing callers are unaffected. ## Data storage -The bot stores channel ↔ agent mappings in a local SQLite database at `maestro-bot.db`. +The bridge stores channel ↔ agent mappings in a local SQLite database at `maestro-bot.db`. Delete this file to reset all channel bindings. ## Discord bot permissions diff --git a/docs/api.md b/docs/api.md index 6da57fc..5ac7e31 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,26 +1,35 @@ -# Maestro-to-Discord Messaging API +# Maestro Bridge HTTP API -Maestro agents can send messages to Discord using the `maestro-discord` CLI. -The bot exposes a local HTTP API that the CLI calls. +Maestro agents can push messages into chat using the `maestro-bridge` CLI (or any HTTP client). The bridge exposes a local HTTP API on `127.0.0.1:API_PORT` (default 3457). + +The legacy binary name `maestro-discord` is preserved as an alias of `maestro-bridge` and is fully equivalent. ## Setup -The API server starts automatically with the bot on port 3457 (configurable via `API_PORT` in `.env`). +The API server starts automatically with the bridge. Port is configurable via `API_PORT` in `.env`. ## CLI usage ```bash -# Send a message to an agent's Discord channel -maestro-discord --agent --message "Hello from Maestro" +# Send a message to an agent's bridge channel (default provider: discord) +maestro-bridge send --agent --message "Hello from Maestro" -# Send with @mention (pings the user set in DISCORD_MENTION_USER_ID) -maestro-discord --agent --message "Build complete!" --mention +# Send with @mention (uses the provider's configured mention target, +# e.g. DISCORD_MENTION_USER_ID for the Discord provider) +maestro-bridge send --agent --message "Build complete!" --mention # Use a custom port -maestro-discord --agent --message "Hello" --port 4000 +maestro-bridge send --agent --message "Hello" --port 4000 + +# Post a styled toast or flash notification +maestro-bridge notify toast --agent --title "Deploy" --message "Done" --color green +maestro-bridge notify flash --agent --message "Tests passing" --color green + +# Post the agent's current status (pulls from `maestro-cli show agent --json`) +maestro-bridge status --agent ``` -If the agent doesn't have a connected Discord channel yet, one is created automatically. +If the agent doesn't have a connected channel yet, one is auto-created. ## Health check @@ -28,36 +37,63 @@ If the agent doesn't have a connected Discord channel yet, one is created automa curl http://127.0.0.1:3457/api/health ``` -Returns `{"success":true,"status":"ok","uptime":123.45}` when the bot is connected. +Returns: + +```json +{ + "success": true, + "status": "ok", + "uptime": 123.45, + "providers": { "discord": true } +} +``` ## API endpoints ### POST /api/send -Sends a message to an agent's Discord channel (auto-creates if needed). +Sends a message to an agent's chat channel (auto-creates if needed). Request: `Content-Type: application/json` ```json -{ "agentId": "string", "message": "string", "mention": false } +{ + "agentId": "string", + "message": "string", + "mention": false, + "provider": "discord" +} ``` +`provider` is optional and defaults to `"discord"`. Must be a name listed in `ENABLED_PROVIDERS`. + +`mention` is rendered by the provider in a platform-appropriate way (Discord prepends `<@DISCORD_MENTION_USER_ID>` to the first part of a multi-part message). + ### GET /api/health -Returns bot status: `{"success":true,"status":"ok","uptime":123.45}` +Returns bridge status: + +```json +{ + "success": true, + "status": "ok", + "uptime": 123.45, + "providers": { "discord": true } +} +``` -Returns `503` with `"status":"not_ready"` if the bot is disconnected. +Returns `503` with `"status":"not_ready"` if no provider is connected. ## Error codes -| Status | Meaning | -| ------ | ----------------------------------------------- | -| `200` | Success | -| `400` | Missing/invalid fields or malformed JSON | -| `404` | Agent not found in Maestro | -| `405` | Method not allowed | -| `413` | Request body exceeds 1 MB | -| `415` | Wrong Content-Type (must be `application/json`) | -| `429` | Rate limited by Discord after 3 retries | -| `500` | Internal server error | -| `503` | Bot not connected to Discord | +| Status | Meaning | +| ------ | -------------------------------------------------------------- | +| `200` | Success | +| `400` | Missing/invalid fields, malformed JSON, or unknown `provider` | +| `404` | Agent not found in Maestro | +| `405` | Method not allowed | +| `413` | Request body exceeds 1 MB | +| `415` | Wrong Content-Type (must be `application/json`) | +| `429` | Rate limited by upstream platform after 3 retries | +| `500` | Internal server error | +| `503` | The named provider is not connected | diff --git a/docs/architecture.md b/docs/architecture.md index 8ea4cf9..8bc44e5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,16 +1,40 @@ # Architecture -## Message flow +Maestro Bridge is built around a **provider-agnostic kernel** plus pluggable **provider adapters**. The kernel handles queueing, agent dispatch, persistence, transcription, and the HTTP API; adapters translate platform events into a small set of kernel types and back out into platform actions. -1. `/agents list` reads running agents from Maestro. -2. `/agents new` creates a text channel under the **Maestro Agents** category. -3. Users mention the bot (`@bot` or `@BotRole`) in an agent channel to create a dedicated thread. -4. The triggering message is forwarded to the agent so the user gets an immediate response. -5. Messages in registered threads are queued and forwarded to `maestro-cli`. -6. The bot adds a ⏳ reaction while waiting, shows typing, and splits long replies. -7. After each response, the bot posts a usage footer with tokens, cost, and context. +## Kernel ↔ Provider contract -## Thread ownership +Each provider implements `BridgeProvider` (from `src/core/types.ts`): + +| Method | Purpose | +| ------------------------------- | ------------------------------------------------------------------------------ | +| `start(ctx)` | Connect to the platform, register event handlers, call `ctx.enqueue` per msg | +| `stop()` | Disconnect and release resources | +| `resolveConversation(message)` | Look up the maestro agent + session bound to this conversation | +| `send(target, msg)` | Post a message into a channel/thread | +| `react?(target, emoji)` | Optional: queue/transcription indicator (e.g. ⏳, 🎧) | +| `sendTyping?(target)` | Optional: typing indicator while the agent thinks | +| `findOrCreateAgentChannel(id)` | Look up or create the platform channel bound to an agent (used by `/api/send`) | +| `isReady()` | Provider readiness for `/api/health` | + +The kernel speaks only in `IncomingMessage` / `OutgoingMessage` / `ChannelTarget`; it has zero `discord.js` imports. + +## Message flow (Discord) + +1. User runs `/agents new` in their server β†’ Discord adapter creates a text channel under the **Maestro Agents** category and registers it in `agent_channels` with `provider='discord'`. +2. User mentions the bot in an agent channel β†’ Discord adapter creates a thread, registers it in `discord_agent_threads`, and forwards the triggering message into the thread. +3. Each thread/channel message becomes an `IncomingMessage` and is passed to `ctx.enqueue`. +4. The kernel queue serializes per `(provider, channelId)`: + - Calls `provider.react('⏳')` and `provider.sendTyping()` + - Resolves the conversation via `provider.resolveConversation` (returns `{agentId, sessionId, readOnly, persistSession}`) + - Downloads any attachments to the agent's `cwd` + - Calls `maestro.send(agentId, content, sessionId, readOnly)` + - Splits the response and calls `provider.send(target, {text})` for each part + - Posts a usage footer: tokens, cost, context % + - Persists the maestro session id on the first response via `conv.persistSession` +5. Errors are logged to `logs/errors.log` and surfaced as a `⚠️` reply in the channel. + +## Thread ownership (Discord) Each thread is bound to the user who created it (via mention or `/session new`). @@ -20,20 +44,41 @@ Each thread is bound to the user who created it (via mention or `/session new`). ## Read-only mode -`/agents readonly on` puts an agent channel into read-only mode. In this mode the bot relays messages from the agent (via the HTTP API) but does **not** forward user messages to the agent. Use `/agents readonly off` to resume normal two-way messaging. +`/agents readonly on` puts an agent channel into read-only mode. In this mode the bridge relays messages from the agent (via the HTTP API) but does **not** forward user messages to the agent. Use `/agents readonly off` to resume normal two-way messaging. + +## Database + +| Table | Owner | Purpose | +| ------------------------- | --------------------- | --------------------------------------------------- | +| `agent_channels` | core | `(provider, channel_id)` β†’ agent + session + flags | +| `discord_agent_threads` | discord provider | Thread β†’ channel + agent + owner + session | + +The schema upgrades on first start: legacy `agent_channels` (single-PK `channel_id`) is rebuilt with composite PK `(provider, channel_id)` and existing rows defaulted to `discord`; legacy `agent_threads` is renamed to `discord_agent_threads`. ## Project layout -| Path | Purpose | -| ------------------------------- | ------------------------------------------------------ | -| `src/config.ts` | Environment variable loading | -| `src/db/index.ts` | SQLite channel registry (`agent_channels` table) | -| `src/services/maestro.ts` | `maestro-cli` wrapper (listAgents, listSessions, send) | -| `src/services/queue.ts` | Per-channel FIFO message queue | -| `src/services/logger.ts` | Logging service | -| `src/server.ts` | Internal HTTP API server | -| `src/commands/` | Slash command handlers | -| `src/handlers/messageCreate.ts` | Discord message listener | -| `src/utils/splitMessage.ts` | Splits long messages for Discord's 2000-char limit | -| `src/deploy-commands.ts` | Registers slash commands with Discord API | -| `bin/maestro-discord.ts` | CLI tool for agent-to-Discord messaging | +| Path | Purpose | +| --------------------------------------------- | ------------------------------------------------------ | +| `src/core/types.ts` | Provider contract types | +| `src/core/queue.ts` | Per-conversation FIFO message queue | +| `src/core/api.ts` | Internal HTTP API server | +| `src/core/providers.ts` | Provider registry | +| `src/core/db/index.ts` | SQLite channel registry | +| `src/core/db/migrations.ts` | Idempotent schema migrations | +| `src/core/maestro.ts` | `maestro-cli` wrapper | +| `src/core/transcription.ts` | ffmpeg + whisper pipeline | +| `src/core/attachments.ts` | Provider-agnostic attachment download | +| `src/providers/discord/adapter.ts` | DiscordProvider implementing BridgeProvider | +| `src/providers/discord/messageCreate.ts` | Discord message β†’ IncomingMessage | +| `src/providers/discord/voice.ts` | Discord voice-message detection | +| `src/providers/discord/commands/` | Slash command handlers | +| `src/providers/discord/deploy.ts` | Registers slash commands with Discord API | +| `src/cli/maestro-bridge.ts` | CLI tool for agent β†’ chat messaging | +| `src/index.ts` | Kernel orchestrator (entry point) | + +## Adding a new provider + +1. Create `src/providers//adapter.ts` exporting a class implementing `BridgeProvider`. +2. Add a `case ''` branch to `loadProvider` in `src/core/providers.ts`. +3. Document the provider's env vars in `.env.example`. +4. Users opt in by setting `ENABLED_PROVIDERS=discord,`. diff --git a/package.json b/package.json index 00c7da1..b746411 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { - "name": "discord-maestro", + "name": "maestro-bridge", "version": "0.1.0", - "description": "Discord Maestro Bot", + "description": "Maestro Bridge β€” connect chat platforms (Discord today, Slack/Teams next) to Maestro AI agents via maestro-cli.", "main": "dist/index.js", "bin": { - "maestro-discord": "dist/cli/maestro-discord.js" + "maestro-bridge": "dist/cli/maestro-bridge.js", + "maestro-discord": "dist/cli/maestro-bridge.js" }, "scripts": { "dev": "tsx src/index.ts", @@ -12,7 +13,8 @@ "start": "node dist/index.js", "deploy-commands": "tsx src/providers/discord/deploy.ts", "test": "npm run build && node --test dist/__tests__/**/*.test.js", - "maestro-discord": "tsx src/cli/maestro-discord.ts" + "maestro-bridge": "tsx src/cli/maestro-bridge.ts", + "maestro-discord": "tsx src/cli/maestro-bridge.ts" }, "keywords": [], "author": "", diff --git a/src/cli/maestro-discord.ts b/src/cli/maestro-bridge.ts similarity index 82% rename from src/cli/maestro-discord.ts rename to src/cli/maestro-bridge.ts index b250808..1625b7c 100644 --- a/src/cli/maestro-discord.ts +++ b/src/cli/maestro-bridge.ts @@ -3,14 +3,16 @@ import { runNotify, notifyUsage } from './verbs/notify'; import { runSend, sendUsage } from './verbs/send'; import { runStatus, statusUsage } from './verbs/status'; -const ROOT_USAGE = `Usage: maestro-discord [options] +const ROOT_USAGE = `Usage: maestro-bridge [options] Verbs: - send Send a message to an agent's Discord channel + send Send a message to an agent's bridge channel notify Post a styled toast/flash notification to an agent's channel status Post the agent's current status (cwd, usage, tokens) to its channel -Run 'maestro-discord --help' for verb-specific options.`; +Run 'maestro-bridge --help' for verb-specific options. + +Note: 'maestro-discord' is preserved as an alias for backwards compatibility.`; function printRootHelp(): void { console.log(ROOT_USAGE); diff --git a/src/cli/verbs/notify.ts b/src/cli/verbs/notify.ts index 45f0ad9..3c43eb0 100644 --- a/src/cli/verbs/notify.ts +++ b/src/cli/verbs/notify.ts @@ -1,9 +1,9 @@ import { parseArgs } from 'node:util'; import { DEFAULT_PORT, fail, ok, parsePort, postToSendApi } from '../lib'; -export const notifyUsage = `Usage: maestro-discord notify [options] +export const notifyUsage = `Usage: maestro-bridge notify [options] -Post a styled notification message to an agent's Discord channel. Color maps +Post a styled notification message to an agent's bridge channel. Color maps to a leading emoji so the alert stands out from regular messages. Subcommands: diff --git a/src/cli/verbs/send.ts b/src/cli/verbs/send.ts index 323f349..cf3d575 100644 --- a/src/cli/verbs/send.ts +++ b/src/cli/verbs/send.ts @@ -1,9 +1,9 @@ import { parseArgs } from 'node:util'; import { DEFAULT_PORT, fail, ok, parsePort, postToSendApi } from '../lib'; -export const sendUsage = `Usage: maestro-discord send --agent --message [--mention] [--port ] +export const sendUsage = `Usage: maestro-bridge send --agent --message [--mention] [--port ] -Send a message to an agent's Discord channel (auto-creates channel if needed). +Send a message to an agent's bridge channel (auto-creates channel if needed). Options: -a, --agent Maestro agent ID (required) diff --git a/src/cli/verbs/status.ts b/src/cli/verbs/status.ts index f65675d..d445c1f 100644 --- a/src/cli/verbs/status.ts +++ b/src/cli/verbs/status.ts @@ -1,10 +1,10 @@ import { parseArgs } from 'node:util'; import { DEFAULT_PORT, fail, ok, parsePort, postToSendApi, runMaestroCli } from '../lib'; -export const statusUsage = `Usage: maestro-discord status --agent [--port ] +export const statusUsage = `Usage: maestro-bridge status --agent [--port ] Fetch agent details from maestro-cli and post a formatted status summary to -the agent's Discord channel. +the agent's bridge channel. Options: -a, --agent Maestro agent ID (required) From 830d79ab034eadba953876679b6ad705282df017 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Tue, 5 May 2026 18:55:06 +0200 Subject: [PATCH 08/31] chore(installer): rename maestro-discord installer to maestro-bridge Imports the installer plumbing that lives on main (install.sh, maestro-discord-ctl, systemd/launchd templates) into this branch and renames it for Maestro Bridge. This branch is stacked on feat/rename-to-bridge, so it includes the provider-agnostic kernel + the package rename + the installer rename together. Land the upstream branches first. Renames: - bin/maestro-discord-ctl.sh -> bin/maestro-bridge-ctl.sh - templates/maestro-discord.service -> templates/maestro-bridge.service - templates/sh.maestro.discord.plist -> templates/sh.maestro.bridge.plist - install dir default: ~/.local/share/maestro-bridge - config dir default: ~/.config/maestro-bridge - systemd unit: maestro-bridge.service - launchd label: sh.maestro.bridge - repo default: RunMaestro/Maestro-Bridge Backwards compatibility: - MAESTRO_DISCORD_* env vars accepted as fallback for every MAESTRO_BRIDGE_* var (so v0.0.x `maestro-discord-ctl update` works). - Legacy ~/.local/share/maestro-discord and ~/.config/maestro-discord are auto-detected when the new dirs do not exist. - install_ctl creates BOTH `maestro-bridge-ctl` and `maestro-discord-ctl` symlinks pointing at the same wrapper. - install.sh removes a legacy maestro-discord systemd unit / launchd plist on upgrade so two services do not run side by side. - uninstall cleans up both legacy and new service files + symlinks. Bot path fixes: - ctl deploy now runs `dist/providers/discord/deploy.js` (the post- refactor location) instead of the old `dist/deploy-commands.js`. Note: a matching change to .github/workflows/release.yml (tarball staging name maestro-discord- -> maestro-bridge-) is required but excluded from this commit because the bot OAuth token lacks workflow scope. Apply the diff in a follow-up commit pushed with a PAT that has 'workflow' scope (the file is left unstaged in the working tree for that purpose). Manual follow-ups (not in this PR): - Rename the GitHub repo RunMaestro/Maestro-Discord to RunMaestro/Maestro-Bridge (auto-redirects existing curl URLs). - Push the .github/workflows/release.yml change with workflow scope. - Cut a v0.0.5+ release once the rename PRs merge. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/maestro-bridge-ctl.sh | 217 +++++++++++++ install.sh | 515 ++++++++++++++++++++++++++++++ templates/maestro-bridge.service | 19 ++ templates/sh.maestro.bridge.plist | 27 ++ 4 files changed, 778 insertions(+) create mode 100755 bin/maestro-bridge-ctl.sh create mode 100755 install.sh create mode 100644 templates/maestro-bridge.service create mode 100644 templates/sh.maestro.bridge.plist diff --git a/bin/maestro-bridge-ctl.sh b/bin/maestro-bridge-ctl.sh new file mode 100755 index 0000000..8899313 --- /dev/null +++ b/bin/maestro-bridge-ctl.sh @@ -0,0 +1,217 @@ +#!/usr/bin/env bash +# Service wrapper for the Maestro Bridge. +# Subcommands: start | stop | restart | status | logs | deploy | update | uninstall | version +# +# Backwards-compat: legacy MAESTRO_DISCORD_* env vars are accepted as fallback. +# A legacy install at ~/.local/share/maestro-discord is auto-detected when +# the new install dir doesn't exist. + +set -euo pipefail + +# Resolve install paths with MAESTRO_DISCORD_* fallback for back-compat. +INSTALL_DIR="${MAESTRO_BRIDGE_HOME:-${MAESTRO_DISCORD_HOME:-}}" +if [ -z "$INSTALL_DIR" ]; then + if [ -d "$HOME/.local/share/maestro-bridge" ]; then + INSTALL_DIR="$HOME/.local/share/maestro-bridge" + elif [ -d "$HOME/.local/share/maestro-discord" ]; then + INSTALL_DIR="$HOME/.local/share/maestro-discord" + else + INSTALL_DIR="$HOME/.local/share/maestro-bridge" + fi +fi + +XDG_CONFIG_PARENT="${XDG_CONFIG_HOME:-$HOME/.config}" +if [ -d "$XDG_CONFIG_PARENT/maestro-bridge" ]; then + CONFIG_DIR="$XDG_CONFIG_PARENT/maestro-bridge" +elif [ -d "$XDG_CONFIG_PARENT/maestro-discord" ]; then + CONFIG_DIR="$XDG_CONFIG_PARENT/maestro-discord" +else + CONFIG_DIR="$XDG_CONFIG_PARENT/maestro-bridge" +fi + +BIN_DIR="${MAESTRO_BRIDGE_BIN_DIR:-${MAESTRO_DISCORD_BIN_DIR:-$HOME/.local/bin}}" +REPO="${MAESTRO_BRIDGE_REPO:-${MAESTRO_DISCORD_REPO:-RunMaestro/Maestro-Bridge}}" +SERVICE_NAME="maestro-bridge" +LAUNCHD_LABEL="sh.maestro.bridge" +LAUNCHD_PLIST="$HOME/Library/LaunchAgents/${LAUNCHD_LABEL}.plist" +# Legacy names β€” used by uninstall to clean up after a v0.0.x install. +LEGACY_SERVICE_NAME="maestro-discord" +LEGACY_LAUNCHD_LABEL="sh.maestro.discord" +LEGACY_LAUNCHD_PLIST="$HOME/Library/LaunchAgents/${LEGACY_LAUNCHD_LABEL}.plist" + +die() { printf 'βœ— %s\n' "$*" >&2; exit 1; } +info() { printf '==> %s\n' "$*"; } + +detect_os() { + case "$(uname -s)" in + Linux) echo linux ;; + Darwin) echo macos ;; + *) echo unsupported ;; + esac +} + +usage() { + cat <<'EOF' +maestro-bridge-ctl β€” control the Maestro Bridge service. +(Alias: maestro-discord-ctl, preserved for back-compat.) + +Usage: + maestro-bridge-ctl + +Commands: + start Start the bridge service + stop Stop the bridge service + restart Restart the bridge service + status Show service status + logs Tail service logs (Ctrl+C to stop) + deploy Deploy slash commands to Discord + update Reinstall the latest release (preserves config) + uninstall Remove the bridge, service files, and CLI symlink + version Print installed version + +Environment: + MAESTRO_BRIDGE_HOME Override install dir (default: ~/.local/share/maestro-bridge) + XDG_CONFIG_HOME Config dir parent (default: ~/.config) + MAESTRO_DISCORD_HOME Accepted as fallback for back-compat with v0.0.x +EOF +} + +require_install() { + [ -d "$INSTALL_DIR" ] || die "Not installed at $INSTALL_DIR. Run install.sh first." +} + +cmd_start() { + require_install + case "$(detect_os)" in + linux) + systemctl --user start "$SERVICE_NAME" + info "Started $SERVICE_NAME (systemd user)" + ;; + macos) + [ -f "$LAUNCHD_PLIST" ] || die "Plist not installed: $LAUNCHD_PLIST" + launchctl load -w "$LAUNCHD_PLIST" 2>/dev/null || launchctl start "$LAUNCHD_LABEL" + info "Started $LAUNCHD_LABEL (launchd)" + ;; + *) die "Unsupported OS for service management" ;; + esac +} + +cmd_stop() { + case "$(detect_os)" in + linux) + systemctl --user stop "$SERVICE_NAME" || true + info "Stopped $SERVICE_NAME" + ;; + macos) + launchctl unload -w "$LAUNCHD_PLIST" 2>/dev/null || launchctl stop "$LAUNCHD_LABEL" || true + info "Stopped $LAUNCHD_LABEL" + ;; + *) die "Unsupported OS for service management" ;; + esac +} + +cmd_restart() { + cmd_stop || true + cmd_start +} + +cmd_status() { + case "$(detect_os)" in + linux) systemctl --user status "$SERVICE_NAME" --no-pager || true ;; + macos) launchctl list | grep -F "$LAUNCHD_LABEL" || echo "(not loaded)" ;; + *) die "Unsupported OS for service management" ;; + esac +} + +cmd_logs() { + case "$(detect_os)" in + linux) journalctl --user -u "$SERVICE_NAME" -f --no-pager ;; + macos) + local log_file="$INSTALL_DIR/logs/maestro-bridge.log" + mkdir -p "$INSTALL_DIR/logs" + [ -f "$log_file" ] || touch "$log_file" + tail -f "$log_file" + ;; + *) die "Unsupported OS for log tailing" ;; + esac +} + +cmd_deploy() { + require_install + [ -f "$INSTALL_DIR/.env" ] || die "Config missing: $INSTALL_DIR/.env" + (cd "$INSTALL_DIR" && node dist/providers/discord/deploy.js) +} + +cmd_update() { + info "Re-running installer to pull the latest release" + local tag config_parent + tag="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | sed -nE 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' | head -n1)" + [ -n "$tag" ] || die "Could not resolve latest release tag" + config_parent="$(dirname "$CONFIG_DIR")" + curl -fsSL "https://raw.githubusercontent.com/${REPO}/${tag}/install.sh" \ + | env \ + MAESTRO_BRIDGE_HOME="$INSTALL_DIR" \ + MAESTRO_BRIDGE_BIN_DIR="$BIN_DIR" \ + MAESTRO_BRIDGE_REPO="$REPO" \ + XDG_CONFIG_HOME="$config_parent" \ + bash +} + +cmd_uninstall() { + read -r -p "Remove $INSTALL_DIR, service files, and CLI symlinks? [y/N] " ans + case "${ans:-n}" in + y|Y|yes|YES) ;; + *) info "Aborted"; exit 0 ;; + esac + cmd_stop || true + case "$(detect_os)" in + linux) + systemctl --user disable --now "$SERVICE_NAME" 2>/dev/null || true + rm -f "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/${SERVICE_NAME}.service" + # Clean up legacy unit if present. + systemctl --user disable --now "$LEGACY_SERVICE_NAME" 2>/dev/null || true + rm -f "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/${LEGACY_SERVICE_NAME}.service" + systemctl --user daemon-reload || true + systemctl --user reset-failed "$SERVICE_NAME" 2>/dev/null || true + systemctl --user reset-failed "$LEGACY_SERVICE_NAME" 2>/dev/null || true + ;; + macos) + rm -f "$LAUNCHD_PLIST" + [ -f "$LEGACY_LAUNCHD_PLIST" ] && { + launchctl unload -w "$LEGACY_LAUNCHD_PLIST" 2>/dev/null || true + rm -f "$LEGACY_LAUNCHD_PLIST" + } + ;; + esac + rm -rf "$INSTALL_DIR" + rm -f "$BIN_DIR/maestro-bridge-ctl" + rm -f "$BIN_DIR/maestro-discord-ctl" + info "Uninstalled. Config preserved at $CONFIG_DIR (delete manually if desired)." +} + +cmd_version() { + if [ -f "$INSTALL_DIR/.version" ]; then + cat "$INSTALL_DIR/.version" + else + die "No version file at $INSTALL_DIR/.version" + fi +} + +main() { + local sub="${1:-}" + case "$sub" in + start) cmd_start ;; + stop) cmd_stop ;; + restart) cmd_restart ;; + status) cmd_status ;; + logs) cmd_logs ;; + deploy) cmd_deploy ;; + update) cmd_update ;; + uninstall) cmd_uninstall ;; + version) cmd_version ;; + -h|--help|help|"") usage ;; + *) usage; exit 2 ;; + esac +} + +main "$@" diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..88da7ef --- /dev/null +++ b/install.sh @@ -0,0 +1,515 @@ +#!/usr/bin/env bash +# Maestro Bridge installer. +# Usage: +# curl -fsSL https://raw.githubusercontent.com/RunMaestro/Maestro-Bridge/main/install.sh | bash +# Re-run to upgrade to the latest release. Existing config is preserved. +# +# Legacy MAESTRO_DISCORD_* env vars are accepted as fallback so v0.0.x +# installs upgrading via `maestro-discord-ctl update` keep working. + +set -Eeuo pipefail + +# Resolve config with MAESTRO_DISCORD_* fallback for back-compat with v0.0.x. +REPO="${MAESTRO_BRIDGE_REPO:-${MAESTRO_DISCORD_REPO:-RunMaestro/Maestro-Bridge}}" +INSTALL_DIR="${MAESTRO_BRIDGE_HOME:-${MAESTRO_DISCORD_HOME:-$HOME/.local/share/maestro-bridge}}" +CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/maestro-bridge" +# If a legacy ~/.config/maestro-discord exists and the new dir doesn't, prefer +# the legacy location so existing users don't lose their .env. +if [ ! -d "$CONFIG_DIR" ] && [ -d "${XDG_CONFIG_HOME:-$HOME/.config}/maestro-discord" ]; then + CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/maestro-discord" +fi +BIN_DIR="${MAESTRO_BRIDGE_BIN_DIR:-${MAESTRO_DISCORD_BIN_DIR:-$HOME/.local/bin}}" +VERSION="${MAESTRO_BRIDGE_VERSION:-${MAESTRO_DISCORD_VERSION:-latest}}" +NODE_MIN_MAJOR=22 +RELEASE_BACKUP="" + +VOICE_FFMPEG="" +VOICE_WHISPER="" +VOICE_MODEL="" +DEFAULT_MODEL_NAME="ggml-base.en.bin" +DEFAULT_MODEL_URL="https://huggingface.co/ggerganov/whisper.cpp/resolve/main/${DEFAULT_MODEL_NAME}" + +rollback_install() { + if [ -n "$RELEASE_BACKUP" ] && [ -d "$RELEASE_BACKUP" ]; then + rm -rf "$INSTALL_DIR" + mv "$RELEASE_BACKUP" "$INSTALL_DIR" + warn "Restored previous install from $RELEASE_BACKUP" + fi +} + +c_red() { printf '\033[31m%s\033[0m' "$*"; } +c_green() { printf '\033[32m%s\033[0m' "$*"; } +c_yellow() { printf '\033[33m%s\033[0m' "$*"; } +c_blue() { printf '\033[34m%s\033[0m' "$*"; } +c_bold() { printf '\033[1m%s\033[0m' "$*"; } + +info() { printf '%s %s\n' "$(c_blue '==>')" "$*"; } +ok() { printf '%s %s\n' "$(c_green 'βœ“')" "$*"; } +warn() { printf '%s %s\n' "$(c_yellow '!')" "$*" >&2; } +die() { printf '%s %s\n' "$(c_red 'βœ—')" "$*" >&2; exit 1; } + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1${2:+ β€” $2}" +} + +detect_os() { + case "$(uname -s)" in + Linux) echo linux ;; + Darwin) echo macos ;; + *) die "Unsupported OS: $(uname -s). Linux and macOS only." ;; + esac +} + +check_node() { + require_cmd node "install Node.js ${NODE_MIN_MAJOR}+ from https://nodejs.org/" + require_cmd npm "install Node.js ${NODE_MIN_MAJOR}+ from https://nodejs.org/" + local major + major="$(node -p 'process.versions.node.split(".")[0]')" + if [ "$major" -lt "$NODE_MIN_MAJOR" ]; then + die "Node.js ${NODE_MIN_MAJOR}+ required (found $(node --version))." + fi + ok "Node.js $(node --version)" +} + +check_maestro_cli() { + if command -v maestro-cli >/dev/null 2>&1; then + ok "maestro-cli found ($(maestro-cli --version 2>/dev/null | head -n1 || echo 'version unknown'))" + else + warn "maestro-cli not found on PATH. The bridge will fail to relay messages until it is installed." + warn "See https://docs.runmaestro.ai/cli for instructions." + fi +} + +resolve_release() { + local api_url tag + if [ "$VERSION" = "latest" ]; then + api_url="https://api.github.com/repos/${REPO}/releases/latest" + else + api_url="https://api.github.com/repos/${REPO}/releases/tags/${VERSION}" + fi + tag="$(curl -fsSL "$api_url" | sed -nE 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' | head -n1)" + [ -n "$tag" ] || die "Could not resolve release tag from ${api_url}" + echo "$tag" +} + +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + return 1 + fi +} + +download_release() { + local tag="$1" dest="$2" + local url="https://github.com/${REPO}/releases/download/${tag}/maestro-bridge-${tag}.tar.gz" + local sha_url="${url}.sha256" + info "Downloading ${tag} from ${url}" + curl -fsSL "$url" -o "$dest" || die "Download failed: $url" + + local sha_file expected actual + sha_file="$(mktemp)" + if curl -fsSL "$sha_url" -o "$sha_file" 2>/dev/null; then + expected="$(awk '{print $1}' "$sha_file")" + rm -f "$sha_file" + if [ -z "$expected" ]; then + die "Empty checksum at $sha_url" + fi + if ! actual="$(sha256_of "$dest")"; then + warn "No sha256sum/shasum on PATH β€” skipping checksum verification" + return + fi + if [ "$expected" != "$actual" ]; then + die "Checksum mismatch for $url (expected $expected, got $actual)" + fi + ok "Verified SHA-256 checksum" + else + rm -f "$sha_file" + warn "Checksum file not published at $sha_url β€” skipping verification" + fi +} + +install_release() { + local tag="$1" tarball="$2" + local staging + staging="$(mktemp -d)" + trap 'rm -rf "$staging"' RETURN + tar -xzf "$tarball" -C "$staging" + local extracted + extracted="$(find "$staging" -mindepth 1 -maxdepth 1 -type d | head -n1)" + [ -n "$extracted" ] || extracted="$staging" + + mkdir -p "$INSTALL_DIR" + if [ -d "$INSTALL_DIR/dist" ]; then + RELEASE_BACKUP="${INSTALL_DIR}.backup.$(date +%s)" + mv "$INSTALL_DIR" "$RELEASE_BACKUP" + mkdir -p "$INSTALL_DIR" + info "Backed up previous install to $RELEASE_BACKUP" + fi + + cp -R "$extracted"/. "$INSTALL_DIR"/ + printf '%s\n' "$tag" > "$INSTALL_DIR/.version" + + if [ -n "$RELEASE_BACKUP" ] && [ -f "$RELEASE_BACKUP/maestro-bot.db" ] && [ ! -f "$INSTALL_DIR/maestro-bot.db" ]; then + cp "$RELEASE_BACKUP/maestro-bot.db" "$INSTALL_DIR/maestro-bot.db" + info "Preserved SQLite database" + fi + + # Migrate .env from a legacy install (e.g. manual git clone) into the XDG + # config dir so write_config will preserve it instead of writing a template. + if [ -n "$RELEASE_BACKUP" ] \ + && [ -f "$RELEASE_BACKUP/.env" ] \ + && [ ! -L "$RELEASE_BACKUP/.env" ] \ + && [ ! -f "$CONFIG_DIR/.env" ]; then + mkdir -p "$CONFIG_DIR" + cp "$RELEASE_BACKUP/.env" "$CONFIG_DIR/.env" + chmod 600 "$CONFIG_DIR/.env" + info "Migrated existing .env β†’ $CONFIG_DIR/.env" + fi + + ok "Extracted release to $INSTALL_DIR" +} + +install_deps() { + info "Installing production dependencies (npm ci --omit=dev)…" + (cd "$INSTALL_DIR" && npm ci --omit=dev --no-audit --no-fund --silent) + ok "Dependencies installed" +} + +prompt_var() { + local desc="$2" default="${3:-}" current="${!1:-}" + if [ -n "$current" ]; then + echo "$current" + return + fi + local prompt=" ${desc}" + [ -n "$default" ] && prompt="${prompt} [${default}]" + prompt="${prompt}: " + local value="" + if [ -r /dev/tty ]; then + read -r -p "$prompt" value "$tmp_env" + mv "$tmp_env" "$env_file" + ln -sf "$env_file" "$INSTALL_DIR/.env" + ok "Wrote $env_file" +} + +config_complete() { + local file="$1" key value + [ -f "$file" ] || return 1 + for key in DISCORD_BOT_TOKEN DISCORD_CLIENT_ID DISCORD_GUILD_ID; do + value="$(sed -nE "s/^${key}=([^#[:space:]]+).*/\1/p" "$file" | head -n1)" + [ -n "$value" ] || return 1 + case "$value" in + your_*) return 1 ;; + esac + done + return 0 +} + +deploy_commands() { + local env_file="$CONFIG_DIR/.env" + if ! config_complete "$env_file"; then + warn "Skipping slash command deployment β€” config at $env_file is incomplete or contains template values." + warn "Edit it and run 'maestro-bridge-ctl deploy' when ready." + return + fi + info "Deploying slash commands to Discord" + if (cd "$INSTALL_DIR" && node dist/providers/discord/deploy.js); then + ok "Slash commands deployed" + else + warn "Slash command deployment failed. Edit $env_file and re-run 'maestro-bridge-ctl deploy'." + fi +} + +expand_tilde() { + local p="$1" + case "$p" in + "~"|"~/"*) printf '%s' "${HOME}${p#\~}" ;; + *) printf '%s' "$p" ;; + esac +} + +prompt_yes_no() { + local prompt="$1" default="${2:-Y}" ans="" + [ -r /dev/tty ] || { printf '%s' "$default"; return; } + read -r -p "$prompt" ans /dev/null 2>&1 || { warn "systemctl not found β€” skipping service install."; return; } + local unit_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" + mkdir -p "$unit_dir" + local template="$INSTALL_DIR/templates/maestro-bridge.service" + [ -f "$template" ] || { warn "Service template missing at $template"; return; } + sed \ + -e "s|@INSTALL_DIR@|$INSTALL_DIR|g" \ + -e "s|@CONFIG_DIR@|$CONFIG_DIR|g" \ + -e "s|@NODE_BIN@|$(command -v node)|g" \ + "$template" > "$unit_dir/maestro-bridge.service" + # Disable+remove a legacy maestro-discord unit if present so we don't leave + # two competing user services running on upgrade. + if [ -f "$unit_dir/maestro-discord.service" ]; then + systemctl --user disable --now maestro-discord 2>/dev/null || true + rm -f "$unit_dir/maestro-discord.service" + info "Removed legacy systemd unit maestro-discord.service" + fi + systemctl --user daemon-reload || true + ok "Installed systemd unit β†’ $unit_dir/maestro-bridge.service" + echo " Enable on login: systemctl --user enable --now maestro-bridge" + echo " (and optionally: loginctl enable-linger \$USER)" +} + +install_service_macos() { + local plist_dir="$HOME/Library/LaunchAgents" + mkdir -p "$plist_dir" + mkdir -p "$INSTALL_DIR/logs" + local template="$INSTALL_DIR/templates/sh.maestro.bridge.plist" + [ -f "$template" ] || { warn "Plist template missing at $template"; return; } + sed \ + -e "s|@INSTALL_DIR@|$INSTALL_DIR|g" \ + -e "s|@CONFIG_DIR@|$CONFIG_DIR|g" \ + -e "s|@NODE_BIN@|$(command -v node)|g" \ + "$template" > "$plist_dir/sh.maestro.bridge.plist" + # Unload+remove a legacy launchd plist if present. + if [ -f "$plist_dir/sh.maestro.discord.plist" ]; then + launchctl unload -w "$plist_dir/sh.maestro.discord.plist" 2>/dev/null || true + rm -f "$plist_dir/sh.maestro.discord.plist" + info "Removed legacy launchd plist sh.maestro.discord.plist" + fi + ok "Installed launchd plist β†’ $plist_dir/sh.maestro.bridge.plist" + echo " Load at login: launchctl load -w $plist_dir/sh.maestro.bridge.plist" +} + +install_service() { + case "$(detect_os)" in + linux) install_service_linux ;; + macos) install_service_macos ;; + esac +} + +main() { + c_bold 'Maestro Bridge installer' + echo + echo + + require_cmd curl + require_cmd tar + require_cmd sed + check_node + check_maestro_cli + + local tag tarball + tag="$(resolve_release)" + info "Target release: ${tag}" + + tarball="$(mktemp)" + trap 'rm -f "$tarball"' EXIT + download_release "$tag" "$tarball" + trap 'rollback_install' ERR + install_release "$tag" "$tarball" + install_deps + trap - ERR + install_ctl + setup_voice + write_config + deploy_commands + install_service + + echo + ok "$(c_bold 'Install complete') β€” version $(c_green "$tag")" + echo + echo " Start: $(c_bold 'maestro-bridge-ctl start')" + echo " Logs: $(c_bold 'maestro-bridge-ctl logs')" + echo " Config: $CONFIG_DIR/.env" + echo +} + +main "$@" diff --git a/templates/maestro-bridge.service b/templates/maestro-bridge.service new file mode 100644 index 0000000..cbff957 --- /dev/null +++ b/templates/maestro-bridge.service @@ -0,0 +1,19 @@ +[Unit] +Description=Maestro Bridge +After=network-online.target +Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=5 + +[Service] +Type=simple +WorkingDirectory=@INSTALL_DIR@ +EnvironmentFile=@CONFIG_DIR@/.env +ExecStart=@NODE_BIN@ @INSTALL_DIR@/dist/index.js +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=default.target diff --git a/templates/sh.maestro.bridge.plist b/templates/sh.maestro.bridge.plist new file mode 100644 index 0000000..4eb61c0 --- /dev/null +++ b/templates/sh.maestro.bridge.plist @@ -0,0 +1,27 @@ + + + + + Label + sh.maestro.bridge + WorkingDirectory + @INSTALL_DIR@ + ProgramArguments + + /bin/bash + -c + set -a; . "@CONFIG_DIR@/.env"; set +a; exec "@NODE_BIN@" "@INSTALL_DIR@/dist/index.js" + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + StandardOutPath + @INSTALL_DIR@/logs/maestro-bridge.log + StandardErrorPath + @INSTALL_DIR@/logs/maestro-bridge.log + + From c502dc28e0a6315f46f9b5f0230f2747e2cad3c6 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 6 May 2026 18:17:07 +0200 Subject: [PATCH 09/31] chore(rename): transition bridge naming to maestro-relay with compat aliases (#30) --- .env.example | 2 +- AGENTS.md | 6 +- README.md | 56 ++++++------- ...tro-bridge-ctl.sh => maestro-relay-ctl.sh} | 62 ++++++++------ docs/api.md | 22 ++--- docs/architecture.md | 4 +- install.sh | 80 +++++++++++-------- package-lock.json | 12 +-- package.json | 16 ++-- .../{maestro-bridge.ts => maestro-relay.ts} | 6 +- src/cli/verbs/notify.ts | 2 +- src/cli/verbs/send.ts | 2 +- src/cli/verbs/status.ts | 2 +- src/core/transcription.ts | 2 +- ...o-bridge.service => maestro-relay.service} | 2 +- ...ro.bridge.plist => sh.maestro.relay.plist} | 6 +- 16 files changed, 155 insertions(+), 127 deletions(-) rename bin/{maestro-bridge-ctl.sh => maestro-relay-ctl.sh} (71%) rename src/cli/{maestro-bridge.ts => maestro-relay.ts} (85%) rename templates/{maestro-bridge.service => maestro-relay.service} (92%) rename templates/{sh.maestro.bridge.plist => sh.maestro.relay.plist} (81%) diff --git a/.env.example b/.env.example index 7715bb5..abc1e86 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ # --- Core (provider-neutral) --- ENABLED_PROVIDERS=discord # comma-separated list of bridge providers to enable (default: discord) -API_PORT=3457 # optional: port for the internal HTTP API used by maestro-bridge CLI +API_PORT=3457 # optional: port for the internal HTTP API used by maestro-relay CLI FFMPEG_PATH=ffmpeg # optional: override ffmpeg executable path WHISPER_CLI_PATH=whisper-cli # optional: override whisper-cli executable path # mkdir -p ./models && curl -L -o models/ggml-base.en.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin diff --git a/AGENTS.md b/AGENTS.md index 165a75f..f6a3423 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Agent Guide -This repo is **Maestro Bridge** β€” a chat-platform-to-Maestro bridge built around a provider-agnostic kernel. Discord is the first provider; Slack/Teams plug in alongside it without touching the kernel. CLAUDE.md is a symlink to this file. +This repo is **Maestro Relay** β€” a chat-platform-to-Maestro bridge built around a provider-agnostic kernel. Discord is the first provider; Slack/Teams plug in alongside it without touching the kernel. CLAUDE.md is a symlink to this file. ## Development workflow @@ -40,11 +40,11 @@ This repo is **Maestro Bridge** β€” a chat-platform-to-Maestro bridge built arou ### CLI -- `src/cli/maestro-bridge.ts` β€” verb dispatcher (`send`, `notify`, `status`) +- `src/cli/maestro-relay.ts` β€” verb dispatcher (`send`, `notify`, `status`) - `src/cli/lib.ts` β€” shared HTTP client for `/api/send` - `src/cli/verbs/` β€” individual verb implementations -The `maestro-discord` binary is registered as an alias of `maestro-bridge` for back-compat. +The `maestro-discord` binary is registered as an alias of `maestro-relay` for back-compat. ### Entry point diff --git a/README.md b/README.md index 19082cf..dcb888c 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# Maestro Bridge +# Maestro Relay [![Made with Maestro](https://raw.githubusercontent.com/RunMaestro/Maestro/main/docs/assets/made-with-maestro.svg)](https://github.com/RunMaestro/Maestro) -**Maestro Bridge** connects chat platforms to [Maestro](https://runmaestro.ai) AI agents through `maestro-cli`. Discord ships in the box; Slack, Teams, and others can be added by dropping in a provider adapter β€” the kernel is provider-agnostic. +**Maestro Relay** connects chat platforms to [Maestro](https://runmaestro.ai) AI agents through `maestro-cli`. Discord ships in the box; Slack, Teams, and others can be added by dropping in a provider adapter β€” the kernel is provider-agnostic. > **Migrating from `discord-maestro`?** Same codebase, new name. The legacy `maestro-discord` binary is preserved as an alias and all `DISCORD_*` env vars work unchanged. See "Migration" below. @@ -21,16 +21,16 @@ - A Discord application + bot token (if running the Discord provider) - [Maestro CLI](https://docs.runmaestro.ai/cli) on your `PATH` -### Install the `maestro-bridge` CLI +### Install the `maestro-relay` CLI -The `maestro-bridge` CLI lets your Maestro agents reach out to chat β€” for example, to ping you when a long-running task finishes. See [docs/api.md](docs/api.md) for usage. +The `maestro-relay` CLI lets your Maestro agents reach out to chat β€” for example, to ping you when a long-running task finishes. See [docs/api.md](docs/api.md) for usage. After building (`npm run build`), create a shell wrapper. macOS / Linux: ```bash -printf '#!/bin/bash\nnode "%s/dist/cli/maestro-bridge.js" "$@"\n' "$(pwd)" | sudo tee /usr/local/bin/maestro-bridge && sudo chmod +x /usr/local/bin/maestro-bridge +printf '#!/bin/bash\nnode "%s/dist/cli/maestro-relay.js" "$@"\n' "$(pwd)" | sudo tee /usr/local/bin/maestro-relay && sudo chmod +x /usr/local/bin/maestro-relay ``` Windows (PowerShell) β€” writes the wrapper to `%USERPROFILE%\bin` and adds it to your user `PATH`: @@ -41,8 +41,8 @@ $binDir = "$env:USERPROFILE\bin" New-Item -ItemType Directory -Force -Path $binDir | Out-Null @" @echo off -node "$repoPath\dist\cli\maestro-bridge.js" %* -"@ | Out-File -FilePath "$binDir\maestro-bridge.cmd" -Encoding ASCII +node "$repoPath\dist\cli\maestro-relay.js" %* +"@ | Out-File -FilePath "$binDir\maestro-relay.cmd" -Encoding ASCII # Add $binDir to user PATH if it isn't already (restart your shell afterwards) $userPath = [Environment]::GetEnvironmentVariable('PATH', 'User') @@ -54,11 +54,11 @@ if (-not ($userPath -split ';' -contains $binDir)) { Or use `npm link`: ```bash -maestro-discord-ctl start # boot the bot -maestro-discord-ctl logs # tail logs -maestro-discord-ctl status # service status -maestro-discord-ctl update # upgrade to latest release (preserves config) -maestro-discord-ctl uninstall # remove install + service files +maestro-relay-ctl start # boot the bot +maestro-relay-ctl logs # tail logs +maestro-relay-ctl status # service status +maestro-relay-ctl update # upgrade to latest release (preserves config) +maestro-relay-ctl uninstall # remove install + service files ``` The legacy `maestro-discord` binary is registered as an alias to the same JS, so existing scripts keep working. @@ -67,20 +67,20 @@ The legacy `maestro-discord` binary is registered as an alias to the same JS, so | Path | Purpose | | ----------------------------- | ---------------------------------------- | -| `~/.local/share/maestro-discord/` | Installed bot (built JS + dependencies) | -| `~/.config/maestro-discord/.env` | Configuration (preserved across updates) | -| `~/.local/bin/maestro-discord-ctl` | Service control wrapper | +| `~/.local/share/maestro-relay/` | Installed bot (built JS + dependencies) | +| `~/.config/maestro-relay/.env` | Configuration (preserved across updates) | +| `~/.local/bin/maestro-relay-ctl` | Service control wrapper | | systemd user / launchd agent | Auto-start unit | -Override any of these with `MAESTRO_DISCORD_HOME`, `XDG_CONFIG_HOME`, or `MAESTRO_DISCORD_BIN_DIR`. Pin a specific version with `MAESTRO_DISCORD_VERSION=v1.0.0`. +Override any of these with `MAESTRO_RELAY_HOME`, `XDG_CONFIG_HOME`, or `MAESTRO_RELAY_BIN_DIR`. Pin a specific version with `MAESTRO_RELAY_VERSION=v1.0.0`. ## Install (development from source) 1. Clone and install: ```bash -git clone https://github.com/RunMaestro/Maestro-Discord.git -cd Maestro-Discord +git clone https://github.com/RunMaestro/Maestro-Relay.git +cd Maestro-Relay npm install ``` @@ -122,7 +122,7 @@ npm run deploy-commands npm run dev ``` -### Install maestro-discord CLI (dev) +### Install maestro-relay CLI (dev) The `maestro-discord` CLI lets your Maestro agents reach out to you on Discord β€” for example, to ping you when a long-running task finishes. See [docs/api.md](docs/api.md) for usage. @@ -131,7 +131,7 @@ After building the project (`npm run build`), create a shell wrapper. macOS / Linux: ```bash -printf '#!/bin/bash\nnode "%s/dist/cli/maestro-discord.js" "$@"\n' "$(pwd)" | sudo tee /usr/local/bin/maestro-discord && sudo chmod +x /usr/local/bin/maestro-discord +printf '#!/bin/bash\nnode "%s/dist/cli/maestro-relay.js" "$@"\n' "$(pwd)" | sudo tee /usr/local/bin/maestro-discord && sudo chmod +x /usr/local/bin/maestro-discord ``` Windows (PowerShell) β€” writes the wrapper to `%USERPROFILE%\bin` and adds it to your user `PATH`: @@ -142,7 +142,7 @@ $binDir = "$env:USERPROFILE\bin" New-Item -ItemType Directory -Force -Path $binDir | Out-Null @" @echo off -node "$repoPath\dist\cli\maestro-discord.js" %* +node "$repoPath\dist\cli\maestro-relay.js" %* "@ | Out-File -FilePath "$binDir\maestro-discord.cmd" -Encoding ASCII # Add $binDir to user PATH if it isn't already (restart your shell afterwards) @@ -180,17 +180,17 @@ brew install ffmpeg whisper-cli On Linux/Windows, install ffmpeg via your package manager and either build `whisper-cli` from the [whisper.cpp](https://github.com/ggerganov/whisper.cpp) repo (then symlink the binary into `~/.local/bin`) or use [Linuxbrew](https://docs.brew.sh/Homebrew-on-Linux). -2. **Production install (curl one-liner)** β€” the installer detects `ffmpeg` + `whisper-cli` on `PATH` and asks whether to enable voice transcription. If you say yes, it asks whether you already have a `ggml-*.bin` model file β€” paste the absolute path to reuse it, or let it download `ggml-base.en.bin` (~142 MB) into `~/.local/share/maestro-discord/models/`. Resolved **absolute** paths are written into `~/.config/maestro-discord/.env`, so the systemd/launchd service finds them regardless of `PATH`. +2. **Production install (curl one-liner)** β€” the installer detects `ffmpeg` + `whisper-cli` on `PATH` and asks whether to enable voice transcription. If you say yes, it asks whether you already have a `ggml-*.bin` model file β€” paste the absolute path to reuse it, or let it download `ggml-base.en.bin` (~142 MB) into `~/.local/share/maestro-relay/models/`. Resolved **absolute** paths are written into `~/.config/maestro-relay/.env`, so the systemd/launchd service finds them regardless of `PATH`. Non-interactive escape hatches: ```bash - MAESTRO_DISCORD_VOICE=1 \ - MAESTRO_DISCORD_MODEL=/abs/path/to/ggml-base.en.bin \ - bash -c "$(curl -fsSL https://raw.githubusercontent.com/RunMaestro/Maestro-Discord/main/install.sh)" + MAESTRO_RELAY_VOICE=1 \ + MAESTRO_RELAY_MODEL=/abs/path/to/ggml-base.en.bin \ + bash -c "$(curl -fsSL https://raw.githubusercontent.com/RunMaestro/Maestro-Relay/main/install.sh)" ``` - `MAESTRO_DISCORD_VOICE=0` opts out; omitting `MAESTRO_DISCORD_MODEL` triggers the download. + `MAESTRO_RELAY_VOICE=0` opts out; omitting `MAESTRO_RELAY_MODEL` triggers the download. 3. **Source install** (npm-based) β€” there's no wizard; download a model and set the paths yourself: @@ -252,13 +252,13 @@ Mention the bot or run `/session new` in an agent channel to create a thread, th ## Agent β†’ chat messaging -Agents can push messages to chat via the `maestro-bridge` CLI / HTTP API. See [docs/api.md](docs/api.md) for usage, endpoints, and error codes. +Agents can push messages to chat via the `maestro-relay` CLI / HTTP API. See [docs/api.md](docs/api.md) for usage, endpoints, and error codes. ## Migration from `discord-maestro` This project was renamed from `discord-maestro` / `Maestro-Discord`. To smooth upgrades: -- The `maestro-discord` binary is preserved as an alias of `maestro-bridge`. Existing scripts that call `maestro-discord send …` keep working unchanged. +- The `maestro-discord` binary is preserved as an alias of `maestro-relay`. Existing scripts that call `maestro-discord send …` keep working unchanged. - All `DISCORD_*` env vars are unchanged. New optional `ENABLED_PROVIDERS` defaults to `discord`. - The SQLite database upgrades automatically on first start: `agent_channels` gains a `provider` column (existing rows default to `discord`); `agent_threads` is renamed to `discord_agent_threads` with rows preserved. No manual migration needed. - The HTTP `/api/send` endpoint accepts an optional `provider` field that defaults to `discord`; existing callers are unaffected. diff --git a/bin/maestro-bridge-ctl.sh b/bin/maestro-relay-ctl.sh similarity index 71% rename from bin/maestro-bridge-ctl.sh rename to bin/maestro-relay-ctl.sh index 8899313..68745c6 100755 --- a/bin/maestro-bridge-ctl.sh +++ b/bin/maestro-relay-ctl.sh @@ -1,38 +1,42 @@ #!/usr/bin/env bash -# Service wrapper for the Maestro Bridge. +# Service wrapper for the Maestro Relay. # Subcommands: start | stop | restart | status | logs | deploy | update | uninstall | version # -# Backwards-compat: legacy MAESTRO_DISCORD_* env vars are accepted as fallback. -# A legacy install at ~/.local/share/maestro-discord is auto-detected when +# Backwards-compat: legacy MAESTRO_BRIDGE_* / MAESTRO_DISCORD_* env vars are accepted as fallback. +# A legacy install at ~/.local/share/maestro-bridge or ~/.local/share/maestro-discord is auto-detected when # the new install dir doesn't exist. set -euo pipefail -# Resolve install paths with MAESTRO_DISCORD_* fallback for back-compat. -INSTALL_DIR="${MAESTRO_BRIDGE_HOME:-${MAESTRO_DISCORD_HOME:-}}" +# Resolve install paths with MAESTRO_BRIDGE_* / MAESTRO_DISCORD_* fallback for back-compat. +INSTALL_DIR="${MAESTRO_RELAY_HOME:-${MAESTRO_BRIDGE_HOME:-${MAESTRO_DISCORD_HOME:-}}}" if [ -z "$INSTALL_DIR" ]; then - if [ -d "$HOME/.local/share/maestro-bridge" ]; then + if [ -d "$HOME/.local/share/maestro-relay" ]; then + INSTALL_DIR="$HOME/.local/share/maestro-relay" + elif [ -d "$HOME/.local/share/maestro-bridge" ]; then INSTALL_DIR="$HOME/.local/share/maestro-bridge" elif [ -d "$HOME/.local/share/maestro-discord" ]; then INSTALL_DIR="$HOME/.local/share/maestro-discord" else - INSTALL_DIR="$HOME/.local/share/maestro-bridge" + INSTALL_DIR="$HOME/.local/share/maestro-relay" fi fi XDG_CONFIG_PARENT="${XDG_CONFIG_HOME:-$HOME/.config}" -if [ -d "$XDG_CONFIG_PARENT/maestro-bridge" ]; then +if [ -d "$XDG_CONFIG_PARENT/maestro-relay" ]; then + CONFIG_DIR="$XDG_CONFIG_PARENT/maestro-relay" +elif [ -d "$XDG_CONFIG_PARENT/maestro-bridge" ]; then CONFIG_DIR="$XDG_CONFIG_PARENT/maestro-bridge" elif [ -d "$XDG_CONFIG_PARENT/maestro-discord" ]; then CONFIG_DIR="$XDG_CONFIG_PARENT/maestro-discord" else - CONFIG_DIR="$XDG_CONFIG_PARENT/maestro-bridge" + CONFIG_DIR="$XDG_CONFIG_PARENT/maestro-relay" fi -BIN_DIR="${MAESTRO_BRIDGE_BIN_DIR:-${MAESTRO_DISCORD_BIN_DIR:-$HOME/.local/bin}}" -REPO="${MAESTRO_BRIDGE_REPO:-${MAESTRO_DISCORD_REPO:-RunMaestro/Maestro-Bridge}}" -SERVICE_NAME="maestro-bridge" -LAUNCHD_LABEL="sh.maestro.bridge" +BIN_DIR="${MAESTRO_RELAY_BIN_DIR:-${MAESTRO_BRIDGE_BIN_DIR:-${MAESTRO_DISCORD_BIN_DIR:-$HOME/.local/bin}}}" +REPO="${MAESTRO_RELAY_REPO:-${MAESTRO_BRIDGE_REPO:-${MAESTRO_DISCORD_REPO:-RunMaestro/Maestro-Relay}}}" +SERVICE_NAME="maestro-relay" +LAUNCHD_LABEL="sh.maestro.relay" LAUNCHD_PLIST="$HOME/Library/LaunchAgents/${LAUNCHD_LABEL}.plist" # Legacy names β€” used by uninstall to clean up after a v0.0.x install. LEGACY_SERVICE_NAME="maestro-discord" @@ -52,26 +56,27 @@ detect_os() { usage() { cat <<'EOF' -maestro-bridge-ctl β€” control the Maestro Bridge service. -(Alias: maestro-discord-ctl, preserved for back-compat.) +maestro-relay-ctl β€” control the Maestro Relay service. +(Aliases: maestro-bridge-ctl and maestro-discord-ctl, preserved for back-compat.) Usage: - maestro-bridge-ctl + maestro-relay-ctl Commands: - start Start the bridge service - stop Stop the bridge service - restart Restart the bridge service + start Start the relay service + stop Stop the relay service + restart Restart the relay service status Show service status logs Tail service logs (Ctrl+C to stop) deploy Deploy slash commands to Discord update Reinstall the latest release (preserves config) - uninstall Remove the bridge, service files, and CLI symlink + uninstall Remove the relay, service files, and CLI symlinks version Print installed version Environment: - MAESTRO_BRIDGE_HOME Override install dir (default: ~/.local/share/maestro-bridge) + MAESTRO_RELAY_HOME Override install dir (default: ~/.local/share/maestro-relay) XDG_CONFIG_HOME Config dir parent (default: ~/.config) + MAESTRO_BRIDGE_HOME Accepted as fallback for back-compat MAESTRO_DISCORD_HOME Accepted as fallback for back-compat with v0.0.x EOF } @@ -127,7 +132,7 @@ cmd_logs() { case "$(detect_os)" in linux) journalctl --user -u "$SERVICE_NAME" -f --no-pager ;; macos) - local log_file="$INSTALL_DIR/logs/maestro-bridge.log" + local log_file="$INSTALL_DIR/logs/maestro-relay.log" mkdir -p "$INSTALL_DIR/logs" [ -f "$log_file" ] || touch "$log_file" tail -f "$log_file" @@ -150,9 +155,9 @@ cmd_update() { config_parent="$(dirname "$CONFIG_DIR")" curl -fsSL "https://raw.githubusercontent.com/${REPO}/${tag}/install.sh" \ | env \ - MAESTRO_BRIDGE_HOME="$INSTALL_DIR" \ - MAESTRO_BRIDGE_BIN_DIR="$BIN_DIR" \ - MAESTRO_BRIDGE_REPO="$REPO" \ + MAESTRO_RELAY_HOME="$INSTALL_DIR" \ + MAESTRO_RELAY_BIN_DIR="$BIN_DIR" \ + MAESTRO_RELAY_REPO="$REPO" \ XDG_CONFIG_HOME="$config_parent" \ bash } @@ -169,6 +174,8 @@ cmd_uninstall() { systemctl --user disable --now "$SERVICE_NAME" 2>/dev/null || true rm -f "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/${SERVICE_NAME}.service" # Clean up legacy unit if present. + systemctl --user disable --now maestro-bridge 2>/dev/null || true + rm -f "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/maestro-bridge.service" systemctl --user disable --now "$LEGACY_SERVICE_NAME" 2>/dev/null || true rm -f "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/${LEGACY_SERVICE_NAME}.service" systemctl --user daemon-reload || true @@ -177,6 +184,10 @@ cmd_uninstall() { ;; macos) rm -f "$LAUNCHD_PLIST" + [ -f "$HOME/Library/LaunchAgents/sh.maestro.bridge.plist" ] && { + launchctl unload -w "$HOME/Library/LaunchAgents/sh.maestro.bridge.plist" 2>/dev/null || true + rm -f "$HOME/Library/LaunchAgents/sh.maestro.bridge.plist" + } [ -f "$LEGACY_LAUNCHD_PLIST" ] && { launchctl unload -w "$LEGACY_LAUNCHD_PLIST" 2>/dev/null || true rm -f "$LEGACY_LAUNCHD_PLIST" @@ -184,6 +195,7 @@ cmd_uninstall() { ;; esac rm -rf "$INSTALL_DIR" + rm -f "$BIN_DIR/maestro-relay-ctl" rm -f "$BIN_DIR/maestro-bridge-ctl" rm -f "$BIN_DIR/maestro-discord-ctl" info "Uninstalled. Config preserved at $CONFIG_DIR (delete manually if desired)." diff --git a/docs/api.md b/docs/api.md index 44b7ffa..a5c27f5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,8 +1,8 @@ -# Maestro Bridge HTTP API +# Maestro Relay HTTP API -Maestro agents can push messages into chat using the `maestro-bridge` CLI (or any HTTP client). The bridge exposes a local HTTP API on `127.0.0.1:API_PORT` (default 3457). +Maestro agents can push messages into chat using the `maestro-relay` CLI (or any HTTP client). The bridge exposes a local HTTP API on `127.0.0.1:API_PORT` (default 3457). -The legacy binary name `maestro-discord` is preserved as an alias of `maestro-bridge` and is fully equivalent. +The legacy binary name `maestro-discord` is preserved as an alias of `maestro-relay` and is fully equivalent. ## Setup @@ -10,26 +10,26 @@ The API server starts automatically with the bridge. Port is configurable via `A ## CLI usage -`maestro-discord` is verb-based. Run `maestro-discord --help` for the full -list, or `maestro-discord --help` for verb-specific options. +`maestro-relay` is verb-based. Run `maestro-relay --help` for the full +list, or `maestro-relay --help` for verb-specific options. ```bash # Send a message to an agent's bridge channel (default provider: discord) -maestro-bridge send --agent --message "Hello from Maestro" +maestro-relay send --agent --message "Hello from Maestro" # Send with @mention (uses the provider's configured mention target, # e.g. DISCORD_MENTION_USER_ID for the Discord provider) -maestro-bridge send --agent --message "Build complete!" --mention +maestro-relay send --agent --message "Build complete!" --mention # Use a custom port -maestro-bridge send --agent --message "Hello" --port 4000 +maestro-relay send --agent --message "Hello" --port 4000 # Post a styled toast or flash notification -maestro-bridge notify toast --agent --title "Deploy" --message "Done" --color green -maestro-bridge notify flash --agent --message "Tests passing" --color green +maestro-relay notify toast --agent --title "Deploy" --message "Done" --color green +maestro-relay notify flash --agent --message "Tests passing" --color green # Post the agent's current status (pulls from `maestro-cli show agent --json`) -maestro-bridge status --agent +maestro-relay status --agent ``` If the agent doesn't have a connected channel yet, one is auto-created. diff --git a/docs/architecture.md b/docs/architecture.md index 8bc44e5..eb49fd0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -Maestro Bridge is built around a **provider-agnostic kernel** plus pluggable **provider adapters**. The kernel handles queueing, agent dispatch, persistence, transcription, and the HTTP API; adapters translate platform events into a small set of kernel types and back out into platform actions. +Maestro Relay is built around a **provider-agnostic kernel** plus pluggable **provider adapters**. The kernel handles queueing, agent dispatch, persistence, transcription, and the HTTP API; adapters translate platform events into a small set of kernel types and back out into platform actions. ## Kernel ↔ Provider contract @@ -73,7 +73,7 @@ The schema upgrades on first start: legacy `agent_channels` (single-PK `channel_ | `src/providers/discord/voice.ts` | Discord voice-message detection | | `src/providers/discord/commands/` | Slash command handlers | | `src/providers/discord/deploy.ts` | Registers slash commands with Discord API | -| `src/cli/maestro-bridge.ts` | CLI tool for agent β†’ chat messaging | +| `src/cli/maestro-relay.ts` | CLI tool for agent β†’ chat messaging | | `src/index.ts` | Kernel orchestrator (entry point) | ## Adding a new provider diff --git a/install.sh b/install.sh index 88da7ef..96418d7 100755 --- a/install.sh +++ b/install.sh @@ -1,25 +1,26 @@ #!/usr/bin/env bash -# Maestro Bridge installer. +# Maestro Relay installer. # Usage: -# curl -fsSL https://raw.githubusercontent.com/RunMaestro/Maestro-Bridge/main/install.sh | bash +# curl -fsSL https://raw.githubusercontent.com/RunMaestro/Maestro-Relay/main/install.sh | bash # Re-run to upgrade to the latest release. Existing config is preserved. # -# Legacy MAESTRO_DISCORD_* env vars are accepted as fallback so v0.0.x +# Legacy MAESTRO_BRIDGE_* / MAESTRO_DISCORD_* env vars are accepted as fallback so v0.0.x # installs upgrading via `maestro-discord-ctl update` keep working. set -Eeuo pipefail -# Resolve config with MAESTRO_DISCORD_* fallback for back-compat with v0.0.x. -REPO="${MAESTRO_BRIDGE_REPO:-${MAESTRO_DISCORD_REPO:-RunMaestro/Maestro-Bridge}}" -INSTALL_DIR="${MAESTRO_BRIDGE_HOME:-${MAESTRO_DISCORD_HOME:-$HOME/.local/share/maestro-bridge}}" -CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/maestro-bridge" -# If a legacy ~/.config/maestro-discord exists and the new dir doesn't, prefer -# the legacy location so existing users don't lose their .env. -if [ ! -d "$CONFIG_DIR" ] && [ -d "${XDG_CONFIG_HOME:-$HOME/.config}/maestro-discord" ]; then +# Resolve config with MAESTRO_BRIDGE_* / MAESTRO_DISCORD_* fallback for back-compat. +REPO="${MAESTRO_RELAY_REPO:-${MAESTRO_BRIDGE_REPO:-${MAESTRO_DISCORD_REPO:-RunMaestro/Maestro-Relay}}}" +INSTALL_DIR="${MAESTRO_RELAY_HOME:-${MAESTRO_BRIDGE_HOME:-${MAESTRO_DISCORD_HOME:-$HOME/.local/share/maestro-relay}}}" +CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/maestro-relay" +# If a legacy config dir exists and the new dir doesn't, prefer legacy location. +if [ ! -d "$CONFIG_DIR" ] && [ -d "${XDG_CONFIG_HOME:-$HOME/.config}/maestro-bridge" ]; then + CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/maestro-bridge" +elif [ ! -d "$CONFIG_DIR" ] && [ -d "${XDG_CONFIG_HOME:-$HOME/.config}/maestro-discord" ]; then CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/maestro-discord" fi -BIN_DIR="${MAESTRO_BRIDGE_BIN_DIR:-${MAESTRO_DISCORD_BIN_DIR:-$HOME/.local/bin}}" -VERSION="${MAESTRO_BRIDGE_VERSION:-${MAESTRO_DISCORD_VERSION:-latest}}" +BIN_DIR="${MAESTRO_RELAY_BIN_DIR:-${MAESTRO_BRIDGE_BIN_DIR:-${MAESTRO_DISCORD_BIN_DIR:-$HOME/.local/bin}}}" +VERSION="${MAESTRO_RELAY_VERSION:-${MAESTRO_BRIDGE_VERSION:-${MAESTRO_DISCORD_VERSION:-latest}}}" NODE_MIN_MAJOR=22 RELEASE_BACKUP="" @@ -104,7 +105,7 @@ sha256_of() { download_release() { local tag="$1" dest="$2" - local url="https://github.com/${REPO}/releases/download/${tag}/maestro-bridge-${tag}.tar.gz" + local url="https://github.com/${REPO}/releases/download/${tag}/maestro-relay-${tag}.tar.gz" local sha_url="${url}.sha256" info "Downloading ${tag} from ${url}" curl -fsSL "$url" -o "$dest" || die "Download failed: $url" @@ -271,14 +272,14 @@ deploy_commands() { local env_file="$CONFIG_DIR/.env" if ! config_complete "$env_file"; then warn "Skipping slash command deployment β€” config at $env_file is incomplete or contains template values." - warn "Edit it and run 'maestro-bridge-ctl deploy' when ready." + warn "Edit it and run 'maestro-relay-ctl deploy' when ready." return fi info "Deploying slash commands to Discord" if (cd "$INSTALL_DIR" && node dist/providers/discord/deploy.js); then ok "Slash commands deployed" else - warn "Slash command deployment failed. Edit $env_file and re-run 'maestro-bridge-ctl deploy'." + warn "Slash command deployment failed. Edit $env_file and re-run 'maestro-relay-ctl deploy'." fi } @@ -303,16 +304,16 @@ prompt_yes_no() { } setup_voice_choose_model() { - local model_env="${MAESTRO_BRIDGE_MODEL:-${MAESTRO_DISCORD_MODEL:-}}" + local model_env="${MAESTRO_RELAY_MODEL:-${MAESTRO_BRIDGE_MODEL:-${MAESTRO_DISCORD_MODEL:-}}}" if [ -n "$model_env" ]; then local m m="$(expand_tilde "$model_env")" if [ -f "$m" ]; then VOICE_MODEL="$m" - ok "Using existing model from MAESTRO_BRIDGE_MODEL: $m" + ok "Using existing model from MAESTRO_RELAY_* env var: $m" return 0 else - warn "MAESTRO_BRIDGE_MODEL=$m not found β€” falling back to download" + warn "Configured model path ($m) not found β€” falling back to download" fi fi @@ -374,7 +375,7 @@ setup_voice() { info "Voice transcription deps not on PATH β€” installing without voice" [ -z "$ffmpeg_path" ] && warn " ffmpeg not found" [ -z "$whisper_path" ] && warn " whisper-cli not found" - warn "Install both, then run 'maestro-bridge-ctl update' to enable transcription." + warn "Install both, then run 'maestro-relay-ctl update' to enable transcription." return 0 fi @@ -382,7 +383,7 @@ setup_voice() { ok "Found whisper-cli: $whisper_path" local enable=0 - local voice_env="${MAESTRO_BRIDGE_VOICE:-${MAESTRO_DISCORD_VOICE:-}}" + local voice_env="${MAESTRO_RELAY_VOICE:-${MAESTRO_BRIDGE_VOICE:-${MAESTRO_DISCORD_VOICE:-}}}" if [ "$voice_env" = "1" ]; then enable=1 elif [ "$voice_env" = "0" ]; then @@ -393,7 +394,7 @@ setup_voice() { enable=1 fi else - info "Non-interactive shell β€” skipping voice setup (set MAESTRO_BRIDGE_VOICE=1 to opt in)" + info "Non-interactive shell β€” skipping voice setup (set MAESTRO_RELAY_VOICE=1 to opt in)" fi if [ "$enable" -ne 1 ]; then @@ -409,14 +410,15 @@ setup_voice() { install_ctl() { mkdir -p "$BIN_DIR" - local ctl="$INSTALL_DIR/bin/maestro-bridge-ctl.sh" + local ctl="$INSTALL_DIR/bin/maestro-relay-ctl.sh" [ -f "$ctl" ] || die "Control script missing at $ctl" chmod +x "$ctl" + ln -sf "$ctl" "$BIN_DIR/maestro-relay-ctl" ln -sf "$ctl" "$BIN_DIR/maestro-bridge-ctl" # Backwards-compat alias for users with `maestro-discord-ctl` in muscle memory # or in scripts. Both point at the same wrapper. ln -sf "$ctl" "$BIN_DIR/maestro-discord-ctl" - ok "Installed maestro-bridge-ctl β†’ $BIN_DIR/maestro-bridge-ctl (alias: maestro-discord-ctl)" + ok "Installed maestro-relay-ctl β†’ $BIN_DIR/maestro-relay-ctl (aliases: maestro-bridge-ctl, maestro-discord-ctl)" case ":$PATH:" in *":$BIN_DIR:"*) : ;; *) warn "$BIN_DIR is not on your PATH. Add it to your shell profile." ;; @@ -427,13 +429,13 @@ install_service_linux() { command -v systemctl >/dev/null 2>&1 || { warn "systemctl not found β€” skipping service install."; return; } local unit_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" mkdir -p "$unit_dir" - local template="$INSTALL_DIR/templates/maestro-bridge.service" + local template="$INSTALL_DIR/templates/maestro-relay.service" [ -f "$template" ] || { warn "Service template missing at $template"; return; } sed \ -e "s|@INSTALL_DIR@|$INSTALL_DIR|g" \ -e "s|@CONFIG_DIR@|$CONFIG_DIR|g" \ -e "s|@NODE_BIN@|$(command -v node)|g" \ - "$template" > "$unit_dir/maestro-bridge.service" + "$template" > "$unit_dir/maestro-relay.service" # Disable+remove a legacy maestro-discord unit if present so we don't leave # two competing user services running on upgrade. if [ -f "$unit_dir/maestro-discord.service" ]; then @@ -441,9 +443,14 @@ install_service_linux() { rm -f "$unit_dir/maestro-discord.service" info "Removed legacy systemd unit maestro-discord.service" fi + if [ -f "$unit_dir/maestro-bridge.service" ]; then + systemctl --user disable --now maestro-bridge 2>/dev/null || true + rm -f "$unit_dir/maestro-bridge.service" + info "Removed legacy systemd unit maestro-bridge.service" + fi systemctl --user daemon-reload || true - ok "Installed systemd unit β†’ $unit_dir/maestro-bridge.service" - echo " Enable on login: systemctl --user enable --now maestro-bridge" + ok "Installed systemd unit β†’ $unit_dir/maestro-relay.service" + echo " Enable on login: systemctl --user enable --now maestro-relay" echo " (and optionally: loginctl enable-linger \$USER)" } @@ -451,21 +458,26 @@ install_service_macos() { local plist_dir="$HOME/Library/LaunchAgents" mkdir -p "$plist_dir" mkdir -p "$INSTALL_DIR/logs" - local template="$INSTALL_DIR/templates/sh.maestro.bridge.plist" + local template="$INSTALL_DIR/templates/sh.maestro.relay.plist" [ -f "$template" ] || { warn "Plist template missing at $template"; return; } sed \ -e "s|@INSTALL_DIR@|$INSTALL_DIR|g" \ -e "s|@CONFIG_DIR@|$CONFIG_DIR|g" \ -e "s|@NODE_BIN@|$(command -v node)|g" \ - "$template" > "$plist_dir/sh.maestro.bridge.plist" + "$template" > "$plist_dir/sh.maestro.relay.plist" # Unload+remove a legacy launchd plist if present. if [ -f "$plist_dir/sh.maestro.discord.plist" ]; then launchctl unload -w "$plist_dir/sh.maestro.discord.plist" 2>/dev/null || true rm -f "$plist_dir/sh.maestro.discord.plist" info "Removed legacy launchd plist sh.maestro.discord.plist" fi - ok "Installed launchd plist β†’ $plist_dir/sh.maestro.bridge.plist" - echo " Load at login: launchctl load -w $plist_dir/sh.maestro.bridge.plist" + if [ -f "$plist_dir/sh.maestro.bridge.plist" ]; then + launchctl unload -w "$plist_dir/sh.maestro.bridge.plist" 2>/dev/null || true + rm -f "$plist_dir/sh.maestro.bridge.plist" + info "Removed legacy launchd plist sh.maestro.bridge.plist" + fi + ok "Installed launchd plist β†’ $plist_dir/sh.maestro.relay.plist" + echo " Load at login: launchctl load -w $plist_dir/sh.maestro.relay.plist" } install_service() { @@ -476,7 +488,7 @@ install_service() { } main() { - c_bold 'Maestro Bridge installer' + c_bold 'Maestro Relay installer' echo echo @@ -506,8 +518,8 @@ main() { echo ok "$(c_bold 'Install complete') β€” version $(c_green "$tag")" echo - echo " Start: $(c_bold 'maestro-bridge-ctl start')" - echo " Logs: $(c_bold 'maestro-bridge-ctl logs')" + echo " Start: $(c_bold 'maestro-relay-ctl start')" + echo " Logs: $(c_bold 'maestro-relay-ctl logs')" echo " Config: $CONFIG_DIR/.env" echo } diff --git a/package-lock.json b/package-lock.json index 003956c..1f2cf71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "discord-maestro", - "version": "0.0.4", + "name": "maestro-relay", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "discord-maestro", - "version": "0.0.4", + "name": "maestro-relay", + "version": "0.1.0", "license": "MIT", "dependencies": { "better-sqlite3": "^12.8.0", @@ -14,7 +14,9 @@ "dotenv": "^16.0.0" }, "bin": { - "maestro-discord": "dist/cli/maestro-discord.js" + "maestro-bridge": "dist/cli/maestro-relay.js", + "maestro-discord": "dist/cli/maestro-relay.js", + "maestro-relay": "dist/cli/maestro-relay.js" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/package.json b/package.json index b746411..3056a54 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,22 @@ { - "name": "maestro-bridge", + "name": "maestro-relay", "version": "0.1.0", - "description": "Maestro Bridge β€” connect chat platforms (Discord today, Slack/Teams next) to Maestro AI agents via maestro-cli.", + "description": "Maestro Relay β€” connect chat platforms (Discord today, Slack/Teams next) to Maestro AI agents via maestro-cli.", "main": "dist/index.js", "bin": { - "maestro-bridge": "dist/cli/maestro-bridge.js", - "maestro-discord": "dist/cli/maestro-bridge.js" + "maestro-relay": "dist/cli/maestro-relay.js", + "maestro-bridge": "dist/cli/maestro-relay.js", + "maestro-discord": "dist/cli/maestro-relay.js" }, "scripts": { "dev": "tsx src/index.ts", - "build": "tsc", + "build": "rm -rf dist && tsc", "start": "node dist/index.js", "deploy-commands": "tsx src/providers/discord/deploy.ts", "test": "npm run build && node --test dist/__tests__/**/*.test.js", - "maestro-bridge": "tsx src/cli/maestro-bridge.ts", - "maestro-discord": "tsx src/cli/maestro-bridge.ts" + "maestro-relay": "tsx src/cli/maestro-relay.ts", + "maestro-bridge": "tsx src/cli/maestro-relay.ts", + "maestro-discord": "tsx src/cli/maestro-relay.ts" }, "keywords": [], "author": "", diff --git a/src/cli/maestro-bridge.ts b/src/cli/maestro-relay.ts similarity index 85% rename from src/cli/maestro-bridge.ts rename to src/cli/maestro-relay.ts index 1625b7c..926a711 100644 --- a/src/cli/maestro-bridge.ts +++ b/src/cli/maestro-relay.ts @@ -3,16 +3,16 @@ import { runNotify, notifyUsage } from './verbs/notify'; import { runSend, sendUsage } from './verbs/send'; import { runStatus, statusUsage } from './verbs/status'; -const ROOT_USAGE = `Usage: maestro-bridge [options] +const ROOT_USAGE = `Usage: maestro-relay [options] Verbs: send Send a message to an agent's bridge channel notify Post a styled toast/flash notification to an agent's channel status Post the agent's current status (cwd, usage, tokens) to its channel -Run 'maestro-bridge --help' for verb-specific options. +Run 'maestro-relay --help' for verb-specific options. -Note: 'maestro-discord' is preserved as an alias for backwards compatibility.`; +Aliases: 'maestro-bridge' and 'maestro-discord' are preserved for backwards compatibility.`; function printRootHelp(): void { console.log(ROOT_USAGE); diff --git a/src/cli/verbs/notify.ts b/src/cli/verbs/notify.ts index 3c43eb0..4ec3624 100644 --- a/src/cli/verbs/notify.ts +++ b/src/cli/verbs/notify.ts @@ -1,7 +1,7 @@ import { parseArgs } from 'node:util'; import { DEFAULT_PORT, fail, ok, parsePort, postToSendApi } from '../lib'; -export const notifyUsage = `Usage: maestro-bridge notify [options] +export const notifyUsage = `Usage: maestro-relay notify [options] Post a styled notification message to an agent's bridge channel. Color maps to a leading emoji so the alert stands out from regular messages. diff --git a/src/cli/verbs/send.ts b/src/cli/verbs/send.ts index cf3d575..2f2db8a 100644 --- a/src/cli/verbs/send.ts +++ b/src/cli/verbs/send.ts @@ -1,7 +1,7 @@ import { parseArgs } from 'node:util'; import { DEFAULT_PORT, fail, ok, parsePort, postToSendApi } from '../lib'; -export const sendUsage = `Usage: maestro-bridge send --agent --message [--mention] [--port ] +export const sendUsage = `Usage: maestro-relay send --agent --message [--mention] [--port ] Send a message to an agent's bridge channel (auto-creates channel if needed). diff --git a/src/cli/verbs/status.ts b/src/cli/verbs/status.ts index d445c1f..bd5f8e3 100644 --- a/src/cli/verbs/status.ts +++ b/src/cli/verbs/status.ts @@ -1,7 +1,7 @@ import { parseArgs } from 'node:util'; import { DEFAULT_PORT, fail, ok, parsePort, postToSendApi, runMaestroCli } from '../lib'; -export const statusUsage = `Usage: maestro-bridge status --agent [--port ] +export const statusUsage = `Usage: maestro-relay status --agent [--port ] Fetch agent details from maestro-cli and post a formatted status summary to the agent's bridge channel. diff --git a/src/core/transcription.ts b/src/core/transcription.ts index 3c3dfe8..104446f 100644 --- a/src/core/transcription.ts +++ b/src/core/transcription.ts @@ -122,7 +122,7 @@ export async function transcribeVoiceAttachment(attachment: IncomingAttachment): ); } - const tempDir = path.join(os.tmpdir(), `maestro-bridge-voice-${randomUUID()}`); + const tempDir = path.join(os.tmpdir(), `maestro-relay-voice-${randomUUID()}`); const inputPath = path.join(tempDir, 'input.ogg'); const wavPath = path.join(tempDir, 'input.wav'); const outputBase = path.join(tempDir, 'transcript'); diff --git a/templates/maestro-bridge.service b/templates/maestro-relay.service similarity index 92% rename from templates/maestro-bridge.service rename to templates/maestro-relay.service index cbff957..d503a62 100644 --- a/templates/maestro-bridge.service +++ b/templates/maestro-relay.service @@ -1,5 +1,5 @@ [Unit] -Description=Maestro Bridge +Description=Maestro Relay After=network-online.target Wants=network-online.target StartLimitIntervalSec=300 diff --git a/templates/sh.maestro.bridge.plist b/templates/sh.maestro.relay.plist similarity index 81% rename from templates/sh.maestro.bridge.plist rename to templates/sh.maestro.relay.plist index 4eb61c0..c6a0ee4 100644 --- a/templates/sh.maestro.bridge.plist +++ b/templates/sh.maestro.relay.plist @@ -3,7 +3,7 @@ Label - sh.maestro.bridge + sh.maestro.relay WorkingDirectory @INSTALL_DIR@ ProgramArguments @@ -20,8 +20,8 @@ StandardOutPath - @INSTALL_DIR@/logs/maestro-bridge.log + @INSTALL_DIR@/logs/maestro-relay.log StandardErrorPath - @INSTALL_DIR@/logs/maestro-bridge.log + @INSTALL_DIR@/logs/maestro-relay.log From 16d8d52d93696a810145818f8166d916dab7a253 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 6 May 2026 19:31:21 +0200 Subject: [PATCH 10/31] feat: add module-switch scaffold + complete relay rename/doc cleanup (#31) * chore(rename): transition bridge naming to maestro-relay with compat aliases * feat(installer,cli): add module switch scaffold and complete relay rename --- AGENTS.md | 8 +++++++- README.md | 9 +++++---- bin/maestro-relay-ctl.sh | 12 ++++++++++-- docs/api.md | 9 ++++++--- install.sh | 26 +++++++++++++++++++++++++- src/cli/lib.ts | 1 + src/cli/verbs/notify.ts | 9 ++++++++- src/cli/verbs/send.ts | 11 +++++++++-- src/cli/verbs/status.ts | 11 +++++++++-- 9 files changed, 80 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f6a3423..506ca32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Agent Guide -This repo is **Maestro Relay** β€” a chat-platform-to-Maestro bridge built around a provider-agnostic kernel. Discord is the first provider; Slack/Teams plug in alongside it without touching the kernel. CLAUDE.md is a symlink to this file. +This repo is **Maestro Relay** β€” a chat-platform-to-Maestro bridge built around a provider-agnostic kernel. Discord is the first provider; Slack/Teams plug in alongside it without touching the kernel. `CLAUDE.md` is a symlink to this file. ## Development workflow @@ -61,6 +61,12 @@ Local API on `127.0.0.1:API_PORT` (default 3457). See [docs/api.md](docs/api.md) 3. Add a section to `.env.example` for the provider's credentials. 4. Provider modules own their own DB tables, command surface, and event handling; the kernel only sees `IncomingMessage` and calls back via `BridgeProvider.send` / `react` / `sendTyping`. +## Installer module switch + +- `install.sh` supports `MAESTRO_RELAY_MODULE` (fallback `MAESTRO_BRIDGE_MODULE`), currently accepting only `discord`. +- Keep installer module selection aligned with runtime `ENABLED_PROVIDERS` and CLI `--provider` support. +- When adding a provider, update installer validation/prompting and `maestro-relay-ctl deploy` routing so deploy behavior is module-aware. + ## Project notes - Source lives in `src/` and is TypeScript. diff --git a/README.md b/README.md index dcb888c..aae9c3d 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ ## Prerequisites -- Node.js 18+ +- Node.js 22+ - A Discord application + bot token (if running the Discord provider) - [Maestro CLI](https://docs.runmaestro.ai/cli) on your `PATH` @@ -73,6 +73,7 @@ The legacy `maestro-discord` binary is registered as an alias to the same JS, so | systemd user / launchd agent | Auto-start unit | Override any of these with `MAESTRO_RELAY_HOME`, `XDG_CONFIG_HOME`, or `MAESTRO_RELAY_BIN_DIR`. Pin a specific version with `MAESTRO_RELAY_VERSION=v1.0.0`. +Choose a provider module at install time via `MAESTRO_RELAY_MODULE` (currently only `discord` is supported). ## Install (development from source) @@ -124,14 +125,14 @@ npm run dev ### Install maestro-relay CLI (dev) -The `maestro-discord` CLI lets your Maestro agents reach out to you on Discord β€” for example, to ping you when a long-running task finishes. See [docs/api.md](docs/api.md) for usage. +The `maestro-relay` CLI lets your Maestro agents reach out to your chat provider β€” for example, to ping you when a long-running task finishes. See [docs/api.md](docs/api.md) for usage. After building the project (`npm run build`), create a shell wrapper. macOS / Linux: ```bash -printf '#!/bin/bash\nnode "%s/dist/cli/maestro-relay.js" "$@"\n' "$(pwd)" | sudo tee /usr/local/bin/maestro-discord && sudo chmod +x /usr/local/bin/maestro-discord +printf '#!/bin/bash\nnode "%s/dist/cli/maestro-relay.js" "$@"\n' "$(pwd)" | sudo tee /usr/local/bin/maestro-relay && sudo chmod +x /usr/local/bin/maestro-relay ``` Windows (PowerShell) β€” writes the wrapper to `%USERPROFILE%\bin` and adds it to your user `PATH`: @@ -143,7 +144,7 @@ New-Item -ItemType Directory -Force -Path $binDir | Out-Null @" @echo off node "$repoPath\dist\cli\maestro-relay.js" %* -"@ | Out-File -FilePath "$binDir\maestro-discord.cmd" -Encoding ASCII +"@ | Out-File -FilePath "$binDir\maestro-relay.cmd" -Encoding ASCII # Add $binDir to user PATH if it isn't already (restart your shell afterwards) $userPath = [Environment]::GetEnvironmentVariable('PATH', 'User') diff --git a/bin/maestro-relay-ctl.sh b/bin/maestro-relay-ctl.sh index 68745c6..e8b9f52 100755 --- a/bin/maestro-relay-ctl.sh +++ b/bin/maestro-relay-ctl.sh @@ -76,6 +76,7 @@ Commands: Environment: MAESTRO_RELAY_HOME Override install dir (default: ~/.local/share/maestro-relay) XDG_CONFIG_HOME Config dir parent (default: ~/.config) + MAESTRO_RELAY_MODULE Installer-time module selection (currently: discord) MAESTRO_BRIDGE_HOME Accepted as fallback for back-compat MAESTRO_DISCORD_HOME Accepted as fallback for back-compat with v0.0.x EOF @@ -143,8 +144,15 @@ cmd_logs() { cmd_deploy() { require_install - [ -f "$INSTALL_DIR/.env" ] || die "Config missing: $INSTALL_DIR/.env" - (cd "$INSTALL_DIR" && node dist/providers/discord/deploy.js) + local env_file="$INSTALL_DIR/.env" + [ -f "$env_file" ] || die "Config missing: $env_file" + local enabled_providers + enabled_providers="$(sed -nE 's/^[[:space:]]*ENABLED_PROVIDERS[[:space:]]*=[[:space:]]*([^#[:space:]]+).*$/\1/p' "$env_file" | head -n1)" + [ -z "$enabled_providers" ] && enabled_providers="discord" + case ",$enabled_providers," in + *,discord,*) (cd "$INSTALL_DIR" && node dist/providers/discord/deploy.js) ;; + *) die "Discord is not enabled in ENABLED_PROVIDERS=$enabled_providers" ;; + esac } cmd_update() { diff --git a/docs/api.md b/docs/api.md index a5c27f5..2ee49d1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -17,6 +17,9 @@ list, or `maestro-relay --help` for verb-specific options. # Send a message to an agent's bridge channel (default provider: discord) maestro-relay send --agent --message "Hello from Maestro" +# Send to an explicit provider/module +maestro-relay send --agent --provider discord --message "Hello from Maestro" + # Send with @mention (uses the provider's configured mention target, # e.g. DISCORD_MENTION_USER_ID for the Discord provider) maestro-relay send --agent --message "Build complete!" --mention @@ -25,11 +28,11 @@ maestro-relay send --agent --message "Build complete!" --mention maestro-relay send --agent --message "Hello" --port 4000 # Post a styled toast or flash notification -maestro-relay notify toast --agent --title "Deploy" --message "Done" --color green +maestro-relay notify toast --agent --provider discord --title "Deploy" --message "Done" --color green maestro-relay notify flash --agent --message "Tests passing" --color green # Post the agent's current status (pulls from `maestro-cli show agent --json`) -maestro-relay status --agent +maestro-relay status --agent --provider discord ``` If the agent doesn't have a connected channel yet, one is auto-created. @@ -92,7 +95,7 @@ Returns `503` with `"status":"not_ready"` if no provider is connected. | Status | Meaning | | ------ | -------------------------------------------------------------- | | `200` | Success | -| `400` | Missing/invalid fields, malformed JSON, or unknown `provider` | +| `400` | Missing/invalid fields, malformed JSON, or unknown `provider` | | `404` | Agent not found in Maestro | | `405` | Method not allowed | | `413` | Request body exceeds 1 MB | diff --git a/install.sh b/install.sh index 96418d7..13e5b59 100755 --- a/install.sh +++ b/install.sh @@ -3,6 +3,7 @@ # Usage: # curl -fsSL https://raw.githubusercontent.com/RunMaestro/Maestro-Relay/main/install.sh | bash # Re-run to upgrade to the latest release. Existing config is preserved. +# Optional: MAESTRO_RELAY_MODULE=discord (currently the only supported module). # # Legacy MAESTRO_BRIDGE_* / MAESTRO_DISCORD_* env vars are accepted as fallback so v0.0.x # installs upgrading via `maestro-discord-ctl update` keep working. @@ -21,6 +22,7 @@ elif [ ! -d "$CONFIG_DIR" ] && [ -d "${XDG_CONFIG_HOME:-$HOME/.config}/maestro-d fi BIN_DIR="${MAESTRO_RELAY_BIN_DIR:-${MAESTRO_BRIDGE_BIN_DIR:-${MAESTRO_DISCORD_BIN_DIR:-$HOME/.local/bin}}}" VERSION="${MAESTRO_RELAY_VERSION:-${MAESTRO_BRIDGE_VERSION:-${MAESTRO_DISCORD_VERSION:-latest}}}" +MODULE="${MAESTRO_RELAY_MODULE:-${MAESTRO_BRIDGE_MODULE:-discord}}" NODE_MIN_MAJOR=22 RELEASE_BACKUP="" @@ -81,6 +83,15 @@ check_maestro_cli() { fi } +normalize_module() { + local raw="$1" + raw="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" + case "$raw" in + discord|'') echo "discord" ;; + *) die "Unsupported module/provider: $raw (supported today: discord)" ;; + esac +} + resolve_release() { local api_url tag if [ "$VERSION" = "latest" ]; then @@ -229,6 +240,7 @@ write_config() { info "Writing config from environment to $env_file" fi local token client_id guild_id allowed + MODULE="$(normalize_module "$MODULE")" token="$(prompt_var DISCORD_BOT_TOKEN 'Discord bot token')" client_id="$(prompt_var DISCORD_CLIENT_ID 'Discord application (client) ID')" guild_id="$(prompt_var DISCORD_GUILD_ID 'Discord guild (server) ID')" @@ -239,7 +251,7 @@ write_config() { chmod 600 "$tmp_env" { printf '# Generated by install.sh on %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" - printf 'ENABLED_PROVIDERS=discord\n' + printf 'ENABLED_PROVIDERS=%s\n' "$MODULE" printf 'API_PORT=3457\n' printf 'DISCORD_BOT_TOKEN=%s\n' "$token" printf 'DISCORD_CLIENT_ID=%s\n' "$client_id" @@ -275,6 +287,18 @@ deploy_commands() { warn "Edit it and run 'maestro-relay-ctl deploy' when ready." return fi + local enabled_providers + enabled_providers="$(sed -nE 's/^[[:space:]]*ENABLED_PROVIDERS[[:space:]]*=[[:space:]]*([^#[:space:]]+).*$/\1/p' "$env_file" | head -n1)" + if [ -z "$enabled_providers" ]; then + enabled_providers="discord" + fi + case ",$enabled_providers," in + *,discord,*) ;; + *) + info "Skipping Discord slash-command deployment (ENABLED_PROVIDERS=$enabled_providers)" + return + ;; + esac info "Deploying slash commands to Discord" if (cd "$INSTALL_DIR" && node dist/providers/discord/deploy.js); then ok "Slash commands deployed" diff --git a/src/cli/lib.ts b/src/cli/lib.ts index 2e3ea93..6d271b6 100644 --- a/src/cli/lib.ts +++ b/src/cli/lib.ts @@ -8,6 +8,7 @@ export interface SendApiPayload { agentId: string; message: string; mention?: boolean; + provider?: string; } export interface SendApiResult { diff --git a/src/cli/verbs/notify.ts b/src/cli/verbs/notify.ts index 4ec3624..29072cb 100644 --- a/src/cli/verbs/notify.ts +++ b/src/cli/verbs/notify.ts @@ -12,6 +12,7 @@ Subcommands: Options: -a, --agent Maestro agent ID (required) + --provider Provider/module name (default: discord) -t, --title Title line (toast only, required) -m, --message Body text (required) -D, --detail Second line (flash only, optional) @@ -53,6 +54,7 @@ export async function runNotify(argv: string[]): Promise { args: rest, options: { agent: { type: 'string', short: 'a' }, + provider: { type: 'string' }, title: { type: 'string', short: 't' }, message: { type: 'string', short: 'm' }, detail: { type: 'string', short: 'D' }, @@ -98,7 +100,12 @@ export async function runNotify(argv: string[]): Promise { try { const result = await postToSendApi( - { agentId, message: content, mention: parsed.values.mention }, + { + agentId, + message: content, + provider: parsed.values.provider, + mention: parsed.values.mention, + }, port, ); ok(result); diff --git a/src/cli/verbs/send.ts b/src/cli/verbs/send.ts index 2f2db8a..d38316a 100644 --- a/src/cli/verbs/send.ts +++ b/src/cli/verbs/send.ts @@ -1,13 +1,14 @@ import { parseArgs } from 'node:util'; import { DEFAULT_PORT, fail, ok, parsePort, postToSendApi } from '../lib'; -export const sendUsage = `Usage: maestro-relay send --agent --message [--mention] [--port ] +export const sendUsage = `Usage: maestro-relay send --agent --message [--provider ] [--mention] [--port ] Send a message to an agent's bridge channel (auto-creates channel if needed). Options: -a, --agent Maestro agent ID (required) -m, --message Message text to send (required) + --provider Provider/module name (default: discord) --mention Mention the user set in DISCORD_MENTION_USER_ID -p, --port API port (default: ${DEFAULT_PORT}) -h, --help Show this help`; @@ -20,6 +21,7 @@ export async function runSend(argv: string[]): Promise { options: { agent: { type: 'string', short: 'a' }, message: { type: 'string', short: 'm' }, + provider: { type: 'string' }, mention: { type: 'boolean', default: false }, port: { type: 'string', short: 'p' }, help: { type: 'boolean', short: 'h', default: false }, @@ -53,7 +55,12 @@ export async function runSend(argv: string[]): Promise { try { const result = await postToSendApi( - { agentId, message, mention: parsed.values.mention }, + { + agentId, + message, + provider: parsed.values.provider, + mention: parsed.values.mention, + }, port, ); ok(result); diff --git a/src/cli/verbs/status.ts b/src/cli/verbs/status.ts index bd5f8e3..ab5ffa6 100644 --- a/src/cli/verbs/status.ts +++ b/src/cli/verbs/status.ts @@ -1,13 +1,14 @@ import { parseArgs } from 'node:util'; import { DEFAULT_PORT, fail, ok, parsePort, postToSendApi, runMaestroCli } from '../lib'; -export const statusUsage = `Usage: maestro-relay status --agent [--port ] +export const statusUsage = `Usage: maestro-relay status --agent [--provider ] [--port ] Fetch agent details from maestro-cli and post a formatted status summary to the agent's bridge channel. Options: -a, --agent Maestro agent ID (required) + --provider Provider/module name (default: discord) --mention Mention the user set in DISCORD_MENTION_USER_ID -p, --port API port (default: ${DEFAULT_PORT}) -h, --help Show this help`; @@ -60,6 +61,7 @@ export async function runStatus(argv: string[]): Promise { args: argv, options: { agent: { type: 'string', short: 'a' }, + provider: { type: 'string' }, mention: { type: 'boolean', default: false }, port: { type: 'string', short: 'p' }, help: { type: 'boolean', short: 'h', default: false }, @@ -105,7 +107,12 @@ export async function runStatus(argv: string[]): Promise { try { const result = await postToSendApi( - { agentId, message: formatStatus(detail), mention: parsed.values.mention }, + { + agentId, + message: formatStatus(detail), + provider: parsed.values.provider, + mention: parsed.values.mention, + }, port, ); ok(result); From 08a6408778a8fd79f095dbbd0d69e7d6c3158d9d Mon Sep 17 00:00:00 2001 From: chr1syy Date: Wed, 6 May 2026 19:36:40 +0200 Subject: [PATCH 11/31] docs(readme): keep npm link separate from relay ctl usage --- README.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/README.md b/README.md index aae9c3d..afe9f12 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,7 @@ if (-not ($userPath -split ';' -contains $binDir)) { Or use `npm link`: ```bash -maestro-relay-ctl start # boot the bot -maestro-relay-ctl logs # tail logs -maestro-relay-ctl status # service status -maestro-relay-ctl update # upgrade to latest release (preserves config) -maestro-relay-ctl uninstall # remove install + service files +npm link ``` The legacy `maestro-discord` binary is registered as an alias to the same JS, so existing scripts keep working. From c8c2060a72ef2229d9f2740e39f4e6df91e32846 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Wed, 6 May 2026 19:38:30 +0200 Subject: [PATCH 12/31] docs(readme): add one-line install and remove duplicate CLI section --- README.md | 70 ++++++++----------------------------------------------- 1 file changed, 10 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index afe9f12..0475fbe 100644 --- a/README.md +++ b/README.md @@ -21,43 +21,23 @@ - A Discord application + bot token (if running the Discord provider) - [Maestro CLI](https://docs.runmaestro.ai/cli) on your `PATH` -### Install the `maestro-relay` CLI - -The `maestro-relay` CLI lets your Maestro agents reach out to chat β€” for example, to ping you when a long-running task finishes. See [docs/api.md](docs/api.md) for usage. - -After building (`npm run build`), create a shell wrapper. - -macOS / Linux: +## Install (production one-liner) ```bash -printf '#!/bin/bash\nnode "%s/dist/cli/maestro-relay.js" "$@"\n' "$(pwd)" | sudo tee /usr/local/bin/maestro-relay && sudo chmod +x /usr/local/bin/maestro-relay -``` - -Windows (PowerShell) β€” writes the wrapper to `%USERPROFILE%\bin` and adds it to your user `PATH`: - -```powershell -$repoPath = (Get-Location).Path -$binDir = "$env:USERPROFILE\bin" -New-Item -ItemType Directory -Force -Path $binDir | Out-Null -@" -@echo off -node "$repoPath\dist\cli\maestro-relay.js" %* -"@ | Out-File -FilePath "$binDir\maestro-relay.cmd" -Encoding ASCII - -# Add $binDir to user PATH if it isn't already (restart your shell afterwards) -$userPath = [Environment]::GetEnvironmentVariable('PATH', 'User') -if (-not ($userPath -split ';' -contains $binDir)) { - [Environment]::SetEnvironmentVariable('PATH', "$binDir;$userPath", 'User') -} +bash -c "$(curl -fsSL https://raw.githubusercontent.com/RunMaestro/Maestro-Relay/main/install.sh)" ``` -Or use `npm link`: +After install: ```bash -npm link +maestro-relay-ctl start # boot the bot +maestro-relay-ctl logs # tail logs +maestro-relay-ctl status # service status +maestro-relay-ctl update # upgrade to latest release (preserves config) +maestro-relay-ctl uninstall # remove install + service files ``` -The legacy `maestro-discord` binary is registered as an alias to the same JS, so existing scripts keep working. +The legacy aliases `maestro-bridge-ctl` and `maestro-discord-ctl` still work for back-compat. ## Quick start @@ -119,37 +99,7 @@ npm run deploy-commands npm run dev ``` -### Install maestro-relay CLI (dev) - -The `maestro-relay` CLI lets your Maestro agents reach out to your chat provider β€” for example, to ping you when a long-running task finishes. See [docs/api.md](docs/api.md) for usage. - -After building the project (`npm run build`), create a shell wrapper. - -macOS / Linux: - -```bash -printf '#!/bin/bash\nnode "%s/dist/cli/maestro-relay.js" "$@"\n' "$(pwd)" | sudo tee /usr/local/bin/maestro-relay && sudo chmod +x /usr/local/bin/maestro-relay -``` - -Windows (PowerShell) β€” writes the wrapper to `%USERPROFILE%\bin` and adds it to your user `PATH`: - -```powershell -$repoPath = (Get-Location).Path -$binDir = "$env:USERPROFILE\bin" -New-Item -ItemType Directory -Force -Path $binDir | Out-Null -@" -@echo off -node "$repoPath\dist\cli\maestro-relay.js" %* -"@ | Out-File -FilePath "$binDir\maestro-relay.cmd" -Encoding ASCII - -# Add $binDir to user PATH if it isn't already (restart your shell afterwards) -$userPath = [Environment]::GetEnvironmentVariable('PATH', 'User') -if (-not ($userPath -split ';' -contains $binDir)) { - [Environment]::SetEnvironmentVariable('PATH', "$binDir;$userPath", 'User') -} -``` - -Or use `npm link`: +Optional for source-based local CLI usage: ```bash npm link From c9ec8c29539a21f70bcc7745220d62869e99f07b Mon Sep 17 00:00:00 2001 From: chr1syy Date: Wed, 6 May 2026 19:45:40 +0200 Subject: [PATCH 13/31] fix(release): rename release tarball to maestro-relay-${tag}.tar.gz install.sh downloads maestro-relay-${tag}.tar.gz but the workflow was still building maestro-discord-${tag}.tar.gz, so a fresh tag would 404 on download. Aligns the release artifact with the installer. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 183fd3b..165c5df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,7 +46,7 @@ jobs: id: package run: | tag="${GITHUB_REF#refs/tags/}" - staging="maestro-discord-${tag}" + staging="maestro-relay-${tag}" mkdir -p "$staging" cp -R dist "$staging/" cp -R bin templates "$staging/" From a2fa4b8e71341fc9858f9b87760e2760c66a975b Mon Sep 17 00:00:00 2001 From: chr1syy Date: Wed, 6 May 2026 20:40:52 +0200 Subject: [PATCH 14/31] fix(installer): strip surrounding quotes from ENABLED_PROVIDERS before matching The sed extraction in deploy_commands (install.sh) and cmd_deploy (maestro-relay-ctl) captured everything that wasn't whitespace or '#', so a quoted value like ENABLED_PROVIDERS="discord" became "discord" (quotes included). The subsequent case ",$ep," in *,discord,*) check then failed against ,"discord", and the installer/ctl falsely reported Discord as not enabled, skipping slash-command deployment. Strip a single layer of leading/trailing single or double quotes after extraction. Reported by Codex review of PR #32. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/maestro-relay-ctl.sh | 2 ++ install.sh | 2 ++ 2 files changed, 4 insertions(+) diff --git a/bin/maestro-relay-ctl.sh b/bin/maestro-relay-ctl.sh index e8b9f52..6f80f66 100755 --- a/bin/maestro-relay-ctl.sh +++ b/bin/maestro-relay-ctl.sh @@ -148,6 +148,8 @@ cmd_deploy() { [ -f "$env_file" ] || die "Config missing: $env_file" local enabled_providers enabled_providers="$(sed -nE 's/^[[:space:]]*ENABLED_PROVIDERS[[:space:]]*=[[:space:]]*([^#[:space:]]+).*$/\1/p' "$env_file" | head -n1)" + enabled_providers="${enabled_providers#\"}"; enabled_providers="${enabled_providers%\"}" + enabled_providers="${enabled_providers#\'}"; enabled_providers="${enabled_providers%\'}" [ -z "$enabled_providers" ] && enabled_providers="discord" case ",$enabled_providers," in *,discord,*) (cd "$INSTALL_DIR" && node dist/providers/discord/deploy.js) ;; diff --git a/install.sh b/install.sh index 13e5b59..3d0c19c 100755 --- a/install.sh +++ b/install.sh @@ -289,6 +289,8 @@ deploy_commands() { fi local enabled_providers enabled_providers="$(sed -nE 's/^[[:space:]]*ENABLED_PROVIDERS[[:space:]]*=[[:space:]]*([^#[:space:]]+).*$/\1/p' "$env_file" | head -n1)" + enabled_providers="${enabled_providers#\"}"; enabled_providers="${enabled_providers%\"}" + enabled_providers="${enabled_providers#\'}"; enabled_providers="${enabled_providers%\'}" if [ -z "$enabled_providers" ]; then enabled_providers="discord" fi From f673a596ac8b1ffbc498c884ab286b33fc70bdf8 Mon Sep 17 00:00:00 2001 From: scriptease <1190368+scriptease@users.noreply.github.com> Date: Thu, 7 May 2026 14:59:15 +0200 Subject: [PATCH 15/31] fix: WAL checkpoint and db.close on graceful shutdown Co-Authored-By: Claude Sonnet 4.6 --- src/index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 3ecf6ed..123aa63 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import './core/db'; // ensure DB is initialized + migrated on startup +import { db } from './core/db'; // initializes + migrates DB on startup import { config } from './core/config'; import { logger } from './core/logger'; import { maestro } from './core/maestro'; @@ -49,6 +49,12 @@ async function main() { console.error(`[bridge] error stopping provider "${name}":`, err); } } + try { + db.exec('PRAGMA wal_checkpoint(RESTART);'); + db.close(); + } catch (err) { + console.error('[bridge] db shutdown error:', err); + } process.exit(0); }; From d2b6fd0fab444b3d99527f704315d3e4bde483a3 Mon Sep 17 00:00:00 2001 From: scriptease <1190368+scriptease@users.noreply.github.com> Date: Thu, 7 May 2026 15:10:23 +0200 Subject: [PATCH 16/31] fix(plist): add /usr/local/bin to launchd PATH so maestro-cli resolves launchd does not inherit the user shell PATH, causing spawn ENOENT when the service tries to run maestro-cli from /usr/local/bin. Co-Authored-By: Claude Sonnet 4.6 --- templates/sh.maestro.relay.plist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/sh.maestro.relay.plist b/templates/sh.maestro.relay.plist index c6a0ee4..6703148 100644 --- a/templates/sh.maestro.relay.plist +++ b/templates/sh.maestro.relay.plist @@ -10,7 +10,7 @@ /bin/bash -c - set -a; . "@CONFIG_DIR@/.env"; set +a; exec "@NODE_BIN@" "@INSTALL_DIR@/dist/index.js" + set -a; . "@CONFIG_DIR@/.env"; set +a; export PATH="/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:$PATH"; exec "@NODE_BIN@" "@INSTALL_DIR@/dist/index.js" RunAtLoad From f4dbccafe49ff8074d77bc43834fbb8b8e69dd86 Mon Sep 17 00:00:00 2001 From: scriptease <1190368+scriptease@users.noreply.github.com> Date: Thu, 7 May 2026 15:24:16 +0200 Subject: [PATCH 17/31] feat(slack): add Slack provider adapter to the bridge kernel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements BridgeProvider for Slack using @slack/bolt. Supports both Socket Mode (dev, no public URL needed) and ExpressReceiver (webhook, production). Slack-specific code is fully contained in src/providers/slack/ β€” src/core/ has no Slack imports. - SlackProvider: start/stop/send/react/resolveConversation/findOrCreateAgentChannel - conversationsDb: slack_agent_conversations table keyed by thread_ts - channelsDb: provider='slack' wrapper over core channelDb - commands: /health, /agents (list/new/disconnect/readonly), /session new - migrations: ensureSlackConversationsTable added to runMigrations - providers.ts: case 'slack' added to loadProvider switch - package.json: @slack/bolt ^3.18.0 Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 11 + package-lock.json | 2848 ++++++++++++++++++++++- package.json | 1 + src/core/db/migrations.ts | 14 + src/core/providers.ts | 4 + src/providers/slack/adapter.ts | 291 +++ src/providers/slack/channelsDb.ts | 31 + src/providers/slack/commands/agents.ts | 232 ++ src/providers/slack/commands/health.ts | 6 + src/providers/slack/commands/session.ts | 70 + src/providers/slack/config.ts | 45 + src/providers/slack/conversationsDb.ts | 58 + src/providers/slack/messageCreate.ts | 54 + 13 files changed, 3621 insertions(+), 44 deletions(-) create mode 100644 src/providers/slack/adapter.ts create mode 100644 src/providers/slack/channelsDb.ts create mode 100644 src/providers/slack/commands/agents.ts create mode 100644 src/providers/slack/commands/health.ts create mode 100644 src/providers/slack/commands/session.ts create mode 100644 src/providers/slack/config.ts create mode 100644 src/providers/slack/conversationsDb.ts create mode 100644 src/providers/slack/messageCreate.ts diff --git a/.env.example b/.env.example index abc1e86..98e6272 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,14 @@ DISCORD_CLIENT_ID=your_application_client_id_here DISCORD_GUILD_ID=your_guild_id_here DISCORD_ALLOWED_USER_IDS=123,456 # comma-separated Discord user IDs allowed to use slash commands DISCORD_MENTION_USER_ID= # optional: Discord user ID to @mention when --mention is used + +# --- Slack provider (loaded only if 'slack' is in ENABLED_PROVIDERS) --- +SLACK_BOT_TOKEN=xoxb-your-bot-token-here +SLACK_SIGNING_SECRET=your_signing_secret_here +SLACK_TEAM_ID=T0123456789 +SLACK_APP_ID=A0123456789 +SLACK_SOCKET_MODE_TOKEN= # optional: app-level token (xapp-...) for Socket Mode; if set, Socket Mode is used instead of HTTP webhooks +SLACK_BOT_PUBLIC_URL= # optional: public HTTPS URL for Bolt's ExpressReceiver (webhook mode only) +SLACK_PORT=3000 # optional: HTTP port for Bolt's ExpressReceiver (webhook mode only, default 3000) +SLACK_ALLOWED_USER_IDS=U123,U456 # optional: comma-separated Slack user IDs allowed to use slash commands +SLACK_MENTION_USER_ID= # optional: Slack user ID to @mention when --mention is used diff --git a/package-lock.json b/package-lock.json index 1f2cf71..c5a7f14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@slack/bolt": "^3.18.0", "better-sqlite3": "^12.8.0", "discord.js": "^14.0.0", "dotenv": "^16.0.0" @@ -789,6 +790,190 @@ "npm": ">=7.0.0" } }, + "node_modules/@slack/bolt": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/@slack/bolt/-/bolt-3.22.0.tgz", + "integrity": "sha512-iKDqGPEJDnrVwxSVlFW6OKTkijd7s4qLBeSufoBsTM0reTyfdp/5izIQVkxNfzjHi3o6qjdYbRXkYad5HBsBog==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.0", + "@slack/oauth": "^2.6.3", + "@slack/socket-mode": "^1.3.6", + "@slack/types": "^2.13.0", + "@slack/web-api": "^6.13.0", + "@types/express": "^4.16.1", + "@types/promise.allsettled": "^1.0.3", + "@types/tsscmp": "^1.0.0", + "axios": "^1.7.4", + "express": "^4.21.0", + "path-to-regexp": "^8.1.0", + "promise.allsettled": "^1.0.2", + "raw-body": "^2.3.3", + "tsscmp": "^1.0.6" + }, + "engines": { + "node": ">=14.21.3", + "npm": ">=6.14.18" + } + }, + "node_modules/@slack/logger": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.1.tgz", + "integrity": "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==", + "license": "MIT", + "dependencies": { + "@types/node": ">=18" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/oauth": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/@slack/oauth/-/oauth-2.6.3.tgz", + "integrity": "sha512-1amXs6xRkJpoH6zSgjVPgGEJXCibKNff9WNDijcejIuVy1HFAl1adh7lehaGNiHhTWfQkfKxBiF+BGn56kvoFw==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^3.0.0", + "@slack/web-api": "^6.12.1", + "@types/jsonwebtoken": "^8.3.7", + "@types/node": ">=12", + "jsonwebtoken": "^9.0.0", + "lodash.isstring": "^4.0.1" + }, + "engines": { + "node": ">=12.13.0", + "npm": ">=6.12.0" + } + }, + "node_modules/@slack/oauth/node_modules/@slack/logger": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-3.0.0.tgz", + "integrity": "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA==", + "license": "MIT", + "dependencies": { + "@types/node": ">=12.0.0" + }, + "engines": { + "node": ">= 12.13.0", + "npm": ">= 6.12.0" + } + }, + "node_modules/@slack/socket-mode": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@slack/socket-mode/-/socket-mode-1.3.6.tgz", + "integrity": "sha512-G+im7OP7jVqHhiNSdHgv2VVrnN5U7KY845/5EZimZkrD4ZmtV0P3BiWkgeJhPtdLuM7C7i6+M6h6Bh+S4OOalA==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^3.0.0", + "@slack/web-api": "^6.12.1", + "@types/node": ">=12.0.0", + "@types/ws": "^7.4.7", + "eventemitter3": "^5", + "finity": "^0.5.4", + "ws": "^7.5.3" + }, + "engines": { + "node": ">=12.13.0", + "npm": ">=6.12.0" + } + }, + "node_modules/@slack/socket-mode/node_modules/@slack/logger": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-3.0.0.tgz", + "integrity": "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA==", + "license": "MIT", + "dependencies": { + "@types/node": ">=12.0.0" + }, + "engines": { + "node": ">= 12.13.0", + "npm": ">= 6.12.0" + } + }, + "node_modules/@slack/socket-mode/node_modules/@types/ws": { + "version": "7.4.7", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz", + "integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@slack/socket-mode/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@slack/types": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@slack/types/-/types-2.21.0.tgz", + "integrity": "sha512-ZLMsKnD5KLRPmhFEoGoBQUD5Pc2bH3xFc5ygHlioEc0WmLGyZGoGCtMff4rpejrFnptrhfxcKpWxW4r3g39R0A==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0", + "npm": ">= 6.12.0" + } + }, + "node_modules/@slack/web-api": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-6.13.0.tgz", + "integrity": "sha512-dv65crIgdh9ZYHrevLU6XFHTQwTyDmNqEqzuIrV+Vqe/vgiG6w37oex5ePDU1RGm2IJ90H8iOvHFvzdEO/vB+g==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^3.0.0", + "@slack/types": "^2.11.0", + "@types/is-stream": "^1.1.0", + "@types/node": ">=12.0.0", + "axios": "^1.7.4", + "eventemitter3": "^3.1.0", + "form-data": "^2.5.0", + "is-electron": "2.2.2", + "is-stream": "^1.1.0", + "p-queue": "^6.6.1", + "p-retry": "^4.0.0" + }, + "engines": { + "node": ">= 12.13.0", + "npm": ">= 6.12.0" + } + }, + "node_modules/@slack/web-api/node_modules/@slack/logger": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-3.0.0.tgz", + "integrity": "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA==", + "license": "MIT", + "dependencies": { + "@types/node": ">=12.0.0" + }, + "engines": { + "node": ">= 12.13.0", + "npm": ">= 6.12.0" + } + }, + "node_modules/@slack/web-api/node_modules/eventemitter3": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -827,6 +1012,25 @@ "@types/node": "*" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -841,6 +1045,45 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, + "node_modules/@types/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@types/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-jkZatu4QVbR60mpIzjINmtS1ZF4a/FqdTUTBeQDVOQ2PYyidtwFKr0B5G6ERukKwliq+7mIXvxyppwzG5EgRYg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -848,6 +1091,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-8.5.9.tgz", + "integrity": "sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.35", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", @@ -857,6 +1115,66 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/promise.allsettled": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/promise.allsettled/-/promise.allsettled-1.0.6.tgz", + "integrity": "sha512-wA0UT0HeT2fGHzIFV9kWpYz5mdoyLxKrTgMdZQM++5h6pYAFH73HXcQhefg24nD1yivUFEn5KU+EF4b+CXJ4Wg==", + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/tsscmp": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/tsscmp/-/tsscmp-1.0.2.tgz", + "integrity": "sha512-cy7BRSU8GYYgxjcx0Py+8lo5MthuDhlyu076KUcYzVNXL23luYgRHkMG2fIFEc6neckeh/ntP82mw+U4QjZq+g==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -1106,6 +1424,19 @@ "npm": ">=7.0.0" } }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1166,6 +1497,127 @@ "dev": true, "license": "MIT" }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/array.prototype.map": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/array.prototype.map/-/array.prototype.map-1.0.8.tgz", + "integrity": "sha512-YocPM7bYYu2hXGxWpb5vwZ8cMeudNHYtYBcUDY4Z1GWa53qcnQMWSl25jeBHNzitjl9HW2AWW4ro/S/nftUaOQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-array-method-boxes-properly": "^1.0.0", + "es-object-atoms": "^1.0.0", + "is-string": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1230,6 +1682,60 @@ "readable-stream": "^3.4.0" } }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/brace-expansion": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", @@ -1267,6 +1773,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -1274,42 +1786,197 @@ "dev": true, "license": "MIT" }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, "engines": { - "node": ">= 8" + "node": ">= 0.8" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -1351,6 +2018,68 @@ "dev": true, "license": "MIT" }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1418,6 +2147,44 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -1427,6 +2194,162 @@ "once": "^1.4.0" } }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-array-method-boxes-properly": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", + "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/esbuild": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", @@ -1465,6 +2388,12 @@ "@esbuild/win32-x64": "0.18.20" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1646,6 +2575,21 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -1655,6 +2599,73 @@ "node": ">=6" } }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/express/node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1712,23 +2723,62 @@ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "license": "MIT" }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", "license": "MIT", "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.8" } }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/finity": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/finity/-/finity-0.5.4.tgz", + "integrity": "sha512-3l+5/1tuw616Lgb0QBimxfdd2TqaDGpfCBpfX6EqtFmqUV3FtQnVEX4Aa62DagYEqnsTIjZcTfbq9msDbXYgyA==", + "license": "MIT" + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -1750,6 +2800,76 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", + "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -1771,6 +2891,107 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-tsconfig": { "version": "4.13.6", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", @@ -1803,6 +3024,144 @@ "node": ">=10.13.0" } }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -1855,6 +3214,163 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-electron": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz", + "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", + "license": "MIT" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1865,6 +3381,40 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -1872,12 +3422,203 @@ "dev": true, "license": "MIT", "dependencies": { - "is-extglob": "^2.1.1" + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1885,6 +3626,28 @@ "dev": true, "license": "ISC" }, + "node_modules/iterate-iterator": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/iterate-iterator/-/iterate-iterator-1.0.2.tgz", + "integrity": "sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/iterate-value": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/iterate-value/-/iterate-value-1.0.2.tgz", + "integrity": "sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ==", + "license": "MIT", + "dependencies": { + "es-get-iterator": "^1.0.2", + "iterate-iterator": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -1906,6 +3669,49 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -1952,6 +3758,48 @@ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lodash.snakecase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", @@ -1971,6 +3819,75 @@ "dev": true, "license": "ISC" }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -2018,7 +3935,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/napi-build-utils": { @@ -2034,6 +3950,15 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-abi": { "version": "3.87.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", @@ -2046,6 +3971,59 @@ "node": ">=10" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2073,6 +4051,32 @@ "node": ">= 0.8.0" } }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -2105,6 +4109,62 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2125,6 +4185,16 @@ "node": ">=8" } }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -2138,6 +4208,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -2191,6 +4270,48 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/promise.allsettled": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/promise.allsettled/-/promise.allsettled-1.0.7.tgz", + "integrity": "sha512-hezvKvQQmsFkOdrZfYxUxkyxl8mgFQeT259Ajj9PXdbg9VzBCWrItOev72JyWxkCD5VSSqAeHmlN3tWx4DlmsA==", + "license": "MIT", + "dependencies": { + "array.prototype.map": "^1.0.5", + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "iterate-value": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -2211,6 +4332,45 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -2240,6 +4400,48 @@ "node": ">= 6" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -2250,6 +4452,34 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2270,6 +4500,45 @@ ], "license": "MIT" }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -2282,6 +4551,112 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2305,6 +4680,78 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -2371,6 +4818,28 @@ "source-map": "^0.6.0" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -2380,6 +4849,62 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -2434,6 +4959,15 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -2503,6 +5037,15 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/tsx": { "version": "3.14.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-3.14.0.tgz", @@ -2546,6 +5089,93 @@ "node": ">= 0.8.0" } }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2584,6 +5214,24 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/undici": { "version": "6.21.3", "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", @@ -2599,6 +5247,15 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -2615,6 +5272,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -2622,6 +5288,15 @@ "dev": true, "license": "MIT" }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2638,6 +5313,91 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 3056a54..1fe1bee 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "author": "", "license": "MIT", "dependencies": { + "@slack/bolt": "^3.18.0", "better-sqlite3": "^12.8.0", "discord.js": "^14.0.0", "dotenv": "^16.0.0" diff --git a/src/core/db/migrations.ts b/src/core/db/migrations.ts index ae9d26a..c2c0a2c 100644 --- a/src/core/db/migrations.ts +++ b/src/core/db/migrations.ts @@ -15,6 +15,7 @@ export function runMigrations(db: Database.Database): void { renameAgentThreadsTable(db); ensureDiscordThreadsTable(db); ensureOwnerUserIdColumn(db); + ensureSlackConversationsTable(db); } export function ensureOwnerUserIdColumn(database: Database.Database): void { @@ -119,3 +120,16 @@ function ensureDiscordThreadsTable(database: Database.Database): void { ) `); } + +function ensureSlackConversationsTable(database: Database.Database): void { + database.exec(` + CREATE TABLE IF NOT EXISTS slack_agent_conversations ( + thread_ts TEXT PRIMARY KEY, + channel_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + owner_user_id TEXT, + session_id TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) + ) + `); +} diff --git a/src/core/providers.ts b/src/core/providers.ts index 7809119..a724533 100644 --- a/src/core/providers.ts +++ b/src/core/providers.ts @@ -22,6 +22,10 @@ async function loadProvider(name: string): Promise { const { DiscordProvider } = await import('../providers/discord/adapter'); return new DiscordProvider(); } + case 'slack': { + const { SlackProvider } = await import('../providers/slack/adapter'); + return new SlackProvider(); + } default: console.warn(`[providers] Unknown provider "${name}" β€” ignoring.`); return null; diff --git a/src/providers/slack/adapter.ts b/src/providers/slack/adapter.ts new file mode 100644 index 0000000..13f00bb --- /dev/null +++ b/src/providers/slack/adapter.ts @@ -0,0 +1,291 @@ +import { App, ExpressReceiver, SocketModeReceiver } from '@slack/bolt'; +import { WebClient } from '@slack/web-api'; +import type { + AgentChannelInfo, + BridgeProvider, + ChannelTarget, + ConversationRecord, + IncomingMessage, + KernelContext, + MessageTarget, + OutgoingMessage, + ReactionHandle, +} from '../../core/types'; +import { maestro } from '../../core/maestro'; +import { logger } from '../../core/logger'; +import { slackConfig } from './config'; +import { channelDb } from './channelsDb'; +import { conversationDb } from './conversationsDb'; +import { createMessageHandler } from './messageCreate'; +import * as health from './commands/health'; +import * as agents from './commands/agents'; +import * as session from './commands/session'; + +/** Matches a Slack message timestamp: digits.digits */ +function isThreadTs(id: string): boolean { + return /^\d+\.\d+$/.test(id); +} + +export class SlackProvider implements BridgeProvider { + readonly name = 'slack'; + private app: App | null = null; + private client: WebClient | null = null; + private started = false; + private pendingChannels = new Map>(); + + async start(ctx: KernelContext): Promise { + const socketModeToken = slackConfig.socketModeToken; + let receiver: SocketModeReceiver | ExpressReceiver; + + if (socketModeToken) { + receiver = new SocketModeReceiver({ appToken: socketModeToken }); + } else { + receiver = new ExpressReceiver({ + signingSecret: slackConfig.signingSecret, + }); + } + + const app = new App({ + token: slackConfig.token, + receiver, + }); + this.app = app; + this.client = new WebClient(slackConfig.token); + + const handleMessage = createMessageHandler(ctx); + + // message events (thread replies only) + app.event('message', async ({ event }) => { + await handleMessage({ event: event as unknown as Record }); + }); + + // app_mention creates a new conversation thread + app.event('app_mention', async ({ event, say }) => { + const eventData = event as unknown as Record; + const text = String(eventData['text'] ?? ''); + const user = String(eventData['user'] ?? ''); + const channel = String(eventData['channel'] ?? ''); + + const allowed = slackConfig.allowedUserIds; + if (allowed.length > 0 && !allowed.includes(user)) { + return; + } + + const channelInfo = channelDb.get(channel); + if (!channelInfo) { + await say('This channel is not registered with an agent. Use `/agents new ` to register one.'); + return; + } + + // Strip bot mention from text + const cleanText = text.replace(/<@[^>]+>/g, '').trim(); + if (!cleanText) { + await say('I received your mention, but no message. Please include a message.'); + return; + } + + try { + const result = await this.client!.chat.postMessage({ + channel, + text: cleanText, + }); + + if (!result.ts) { + await say('Failed to create conversation thread.'); + return; + } + + const threadTs = result.ts; + conversationDb.register(threadTs, channel, channelInfo.agent_id, user); + + // Enqueue the initial message from the mention + const message: IncomingMessage = { + provider: 'slack', + messageId: threadTs, + channelId: threadTs, + authorId: user, + authorName: user, + content: cleanText, + attachments: [], + isThread: true, + raw: eventData, + }; + ctx.enqueue(message); + } catch (err) { + void logger.error('slack/app_mention', String(err)); + await say('Failed to create conversation thread.'); + } + }); + + // slash commands + app.command('/health', async (args) => { await health.handle(args); }); + app.command('/agents', async (args) => { await agents.handle(args); }); + app.command('/session', async (args) => { await session.handle(args); }); + + if (socketModeToken) { + await app.start(); + } else { + await app.start(slackConfig.port); + } + this.started = true; + } + + async stop(): Promise { + if (this.app) { + await this.app.stop(); + this.app = null; + this.client = null; + this.started = false; + } + } + + isReady(): boolean { + return this.started; + } + + resolveConversation(message: IncomingMessage): ConversationRecord | null { + if (message.isThread) { + // channelId is the thread_ts for Slack thread messages + const convo = conversationDb.get(message.channelId); + if (!convo) return null; + const channelInfo = channelDb.get(convo.channel_id); + return { + agentId: convo.agent_id, + sessionId: convo.session_id ?? null, + readOnly: !!(channelInfo?.read_only), + persistSession: (sessionId: string) => + conversationDb.updateSession(message.channelId, sessionId), + }; + } + + const channelInfo = channelDb.get(message.channelId); + if (!channelInfo) return null; + return { + agentId: channelInfo.agent_id, + sessionId: channelInfo.session_id ?? null, + readOnly: !!channelInfo.read_only, + persistSession: (sessionId: string) => + channelDb.updateSession(message.channelId, sessionId), + }; + } + + async send(target: ChannelTarget, msg: OutgoingMessage): Promise { + if (!this.client) throw new Error('Slack client not initialised'); + + let text = msg.text; + if (msg.mention && slackConfig.mentionUserId) { + text = `<@${slackConfig.mentionUserId}> ${text}`; + } + + if (isThreadTs(target.channelId)) { + // target is a thread_ts β€” look up parent channel + const convo = conversationDb.get(target.channelId); + if (!convo) throw new Error(`No conversation found for thread_ts ${target.channelId}`); + await this.client.chat.postMessage({ + channel: convo.channel_id, + thread_ts: target.channelId, + text, + }); + } else { + await this.client.chat.postMessage({ channel: target.channelId, text }); + } + } + + async react(target: MessageTarget, emoji: string): Promise { + if (!this.client) throw new Error('Slack client not initialised'); + + // Resolve channel: target.channelId may be a thread_ts or a channel ID + let channel: string; + let timestamp: string; + + if (isThreadTs(target.channelId)) { + const convo = conversationDb.get(target.channelId); + if (!convo) throw new Error(`No conversation found for thread_ts ${target.channelId}`); + channel = convo.channel_id; + timestamp = target.messageId; + } else { + channel = target.channelId; + timestamp = target.messageId; + } + + await this.client.reactions.add({ channel, timestamp, name: emoji }); + + return { + remove: async () => { + await this.client!.reactions.remove({ channel, timestamp, name: emoji }); + }, + }; + } + + // Slack does not expose a per-user typing indicator via the Web API + async sendTyping(_target: ChannelTarget): Promise { + // no-op + } + + async findOrCreateAgentChannel(agentId: string): Promise { + const existing = channelDb.getByAgentId(agentId); + if (existing) { + return { + channelId: existing.channel_id, + agentId: existing.agent_id, + agentName: existing.agent_name, + }; + } + + const pending = this.pendingChannels.get(agentId); + if (pending) return pending; + + const promise = (async () => { + if (!this.client) throw new Error('Slack client not initialised'); + + const allAgents = await maestro.listAgents(); + const agent = allAgents.find((a) => a.id === agentId); + if (!agent) throw new Error(`Agent not found: ${agentId}`); + + const sanitizedName = agent.name + .toLowerCase() + .replace(/[^a-z0-9-_]/g, '-') + .replace(/-+/g, '-') + .substring(0, 70); + const channelName = `maestro-${sanitizedName}`; + + let channelId: string | undefined; + + try { + const listRes = await this.client.conversations.list({ + exclude_archived: false, + types: 'public_channel', + limit: 1000, + }); + const found = listRes.channels?.find((ch) => ch.name === channelName); + if (found?.id) { + channelId = found.id; + if (found.is_archived) { + await this.client.conversations.unarchive({ channel: channelId }); + } + } + } catch { + // ignore β€” will create below + } + + if (!channelId) { + const res = await this.client.conversations.create({ + name: channelName, + is_private: false, + }); + if (!res.channel?.id) throw new Error(`Failed to create Slack channel for agent ${agentId}`); + channelId = res.channel.id; + } + + channelDb.register(channelId, agent.id, agent.name); + return { channelId, agentId: agent.id, agentName: agent.name }; + })(); + + this.pendingChannels.set(agentId, promise); + try { + return await promise; + } finally { + this.pendingChannels.delete(agentId); + } + } +} diff --git a/src/providers/slack/channelsDb.ts b/src/providers/slack/channelsDb.ts new file mode 100644 index 0000000..86624e5 --- /dev/null +++ b/src/providers/slack/channelsDb.ts @@ -0,0 +1,31 @@ +import { channelDb as core, type AgentChannel } from '../../core/db'; + +/** + * Slack-side wrapper around the provider-aware core channel registry. + * Pre-binds `provider='slack'` so adapter code reads naturally. + */ +export const channelDb = { + register(channelId: string, agentId: string, agentName: string): void { + core.register('slack', channelId, agentId, agentName, null); + }, + get(channelId: string): AgentChannel | undefined { + return core.get('slack', channelId); + }, + getByAgentId(agentId: string): AgentChannel | undefined { + return core.getByAgentId('slack', agentId); + }, + updateSession(channelId: string, sessionId: string | null): void { + core.updateSession('slack', channelId, sessionId); + }, + setReadOnly(channelId: string, readOnly: boolean): void { + core.setReadOnly('slack', channelId, readOnly); + }, + remove(channelId: string): void { + core.remove('slack', channelId); + }, + listByAgentId(agentId: string): AgentChannel[] { + return core.listByAgentId('slack', agentId); + }, +}; + +export type { AgentChannel } from '../../core/db'; diff --git a/src/providers/slack/commands/agents.ts b/src/providers/slack/commands/agents.ts new file mode 100644 index 0000000..941b939 --- /dev/null +++ b/src/providers/slack/commands/agents.ts @@ -0,0 +1,232 @@ +import type { SlackCommandMiddlewareArgs } from '@slack/bolt'; +import { WebClient } from '@slack/web-api'; +import { slackConfig } from '../config'; +import { channelDb } from '../channelsDb'; +import { conversationDb } from '../conversationsDb'; +import { maestro } from '../../../core/maestro'; + +export async function handle({ + ack, + say, + command, +}: SlackCommandMiddlewareArgs): Promise { + await ack(); + + const allowed = slackConfig.allowedUserIds; + if (allowed.length > 0 && !allowed.includes(command.user_id)) { + await say('You are not authorized to use this command.'); + return; + } + + try { + const [subcommand, ...args] = (command.text || '').trim().split(/\s+/); + + switch (subcommand?.toLowerCase()) { + case 'new': + await handleNew(say as any, command.channel_id, args[0], command.user_id); + break; + case 'disconnect': + await handleDisconnect(say as any, command.channel_id, args[0]); + break; + case 'readonly': + await handleReadonly(say as any, command.channel_id, args[0], args[1]); + break; + case 'list': + case '': + case undefined: + await handleList(say as any); + break; + default: + await say( + `Unknown subcommand: \`${subcommand}\`. Try: \`list\`, \`new\`, \`disconnect\`, \`readonly\``, + ); + } + } catch (err) { + await say('Failed to execute agents command.'); + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function handleList(say: any): Promise { + const agents = await maestro.listAgents(); + + if (agents.length === 0) { + await say('No agents available.'); + return; + } + + const blocks: object[] = [ + { + type: 'section', + text: { type: 'mrkdwn', text: '*Available Maestro Agents:*' }, + }, + ]; + + for (const agent of agents) { + blocks.push({ + type: 'section', + text: { type: 'mrkdwn', text: `β€’ *${agent.name}* (\`${agent.id}\`)` }, + }); + } + + blocks.push({ type: 'divider' }); + blocks.push({ + type: 'section', + text: { + type: 'mrkdwn', + text: '*Register an agent:* `/agents new `\n*Unregister:* `/agents disconnect `\n*Toggle read-only:* `/agents readonly `', + }, + }); + + await say({ blocks }); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function handleNew( + say: any, + channelId: string, + agentId: string | undefined, + userId?: string, +): Promise { + if (!agentId) { + await say('Usage: `/agents new `'); + return; + } + + const agents = await maestro.listAgents(); + let agent = agents.find((a) => a.id === agentId); + if (!agent) { + agent = agents.find((a) => a.name.toLowerCase() === agentId.toLowerCase()); + } + if (!agent) { + await say(`Agent \`${agentId}\` not found. Use \`/agents list\` to see available agents.`); + return; + } + + const client = new WebClient(slackConfig.token); + const sanitizedName = agent.name + .toLowerCase() + .replace(/[^a-z0-9-_]/g, '-') + .replace(/-+/g, '-') + .substring(0, 70); + const channelName = `maestro-${sanitizedName}`; + + let newChannelId: string | undefined; + let isArchived = false; + + try { + const listRes = await client.conversations.list({ + exclude_archived: false, + types: 'public_channel', + limit: 1000, + }); + const existing = listRes.channels?.find((ch) => ch.name === channelName); + if (existing?.id) { + newChannelId = existing.id; + isArchived = existing.is_archived ?? false; + } + } catch { + // ignore list error β€” will create below + } + + if (!newChannelId) { + const res = await client.conversations.create({ name: channelName, is_private: false }); + if (!res.channel?.id) { + await say('Failed to create channel for agent.'); + return; + } + newChannelId = res.channel.id; + } + + if (isArchived) { + try { + await client.conversations.unarchive({ channel: newChannelId }); + } catch { + const fallbackName = `${channelName}-${Date.now().toString().slice(-6)}`.substring(0, 80); + const res = await client.conversations.create({ name: fallbackName, is_private: false }); + if (!res.channel?.id) { + await say('Failed to create channel for agent.'); + return; + } + newChannelId = res.channel.id; + } + } + + if (userId) { + try { + await client.conversations.invite({ channel: newChannelId, users: userId }); + } catch { + // non-fatal + } + } + + channelDb.register(newChannelId, agent.id, agent.name); + + await client.chat.postMessage({ + channel: newChannelId, + text: `*${agent.name}* agent is ready.\n\nMention me (@app) in this channel to start a conversation thread.`, + }); + + await say(`Created channel <#${newChannelId}> for *${agent.name}* (\`${agent.id}\`)`); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function handleDisconnect( + say: any, + channelId: string, + agentId: string | undefined, +): Promise { + const existing = channelDb.get(channelId); + + if (!existing) { + await say('No agent is registered in this channel.'); + return; + } + + if (agentId && existing.agent_id !== agentId) { + await say(`Agent \`${agentId}\` is not registered in this channel.`); + return; + } + + const client = new WebClient(slackConfig.token); + await say(`Agent *${existing.agent_name}* has been disconnected. This channel is now archived.`); + + conversationDb.removeByChannel(channelId); + channelDb.remove(channelId); + + try { + await client.conversations.archive({ channel: channelId }); + } catch { + // non-fatal if archive fails + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function handleReadonly( + say: any, + channelId: string, + agentId: string | undefined, + mode: string | undefined, +): Promise { + if (!agentId || !mode) { + await say('Usage: `/agents readonly `'); + return; + } + + const existing = channelDb.get(channelId); + + if (!existing) { + await say('No agent is registered in this channel.'); + return; + } + + if (existing.agent_id !== agentId) { + await say(`Agent \`${agentId}\` is not registered in this channel.`); + return; + } + + const readOnly = mode.toLowerCase() === 'on'; + channelDb.setReadOnly(channelId, readOnly); + const status = readOnly ? 'read-only' : 'read-write'; + await say(`Agent *${existing.agent_name}* is now in ${status} mode for this channel.`); +} diff --git a/src/providers/slack/commands/health.ts b/src/providers/slack/commands/health.ts new file mode 100644 index 0000000..0563b61 --- /dev/null +++ b/src/providers/slack/commands/health.ts @@ -0,0 +1,6 @@ +import type { SlackCommandMiddlewareArgs } from '@slack/bolt'; + +export async function handle({ ack, say }: SlackCommandMiddlewareArgs): Promise { + await ack(); + await say('Maestro relay is healthy and running.'); +} diff --git a/src/providers/slack/commands/session.ts b/src/providers/slack/commands/session.ts new file mode 100644 index 0000000..d25712c --- /dev/null +++ b/src/providers/slack/commands/session.ts @@ -0,0 +1,70 @@ +import type { SlackCommandMiddlewareArgs } from '@slack/bolt'; +import { WebClient } from '@slack/web-api'; +import { slackConfig } from '../config'; +import { channelDb } from '../channelsDb'; +import { conversationDb } from '../conversationsDb'; + +export async function handle({ + ack, + say, + command, +}: SlackCommandMiddlewareArgs): Promise { + await ack(); + + const allowed = slackConfig.allowedUserIds; + if (allowed.length > 0 && !allowed.includes(command.user_id)) { + await say('You are not authorized to use this command.'); + return; + } + + try { + const [subcommand, ...args] = (command.text || '').trim().split(/\s+/); + + switch (subcommand?.toLowerCase()) { + case 'new': + await handleNew(say, command.channel_id, args[0], command.user_id); + break; + default: + await say(`Unknown subcommand: \`${subcommand}\`. Try: \`new [session-name]\``); + } + } catch (err) { + await say('Failed to execute session command.'); + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function handleNew( + say: any, + channelId: string, + sessionName: string | undefined, + userId?: string, +): Promise { + const agentChannel = channelDb.get(channelId); + if (!agentChannel) { + await say('No agent is registered in this channel. Use `/agents new ` first.'); + return; + } + + const { agent_id: agentId, agent_name: agentName } = agentChannel; + const client = new WebClient(slackConfig.token); + const sessionLabel = sessionName ? ` β€” ${sessionName}` : ''; + + const msgRes = await client.chat.postMessage({ + channel: channelId, + text: `*${agentName}* β€” ready for a new session${sessionLabel}.\nType your first message to begin.${userId ? ` Only <@${userId}> can interact with the agent in this thread.` : ''}`, + }); + + if (!msgRes.ts) { + await say('Failed to create session message.'); + return; + } + + const threadTs = msgRes.ts; + conversationDb.register(threadTs, channelId, agentId, userId ?? null); + + await client.chat.postMessage({ + channel: channelId, + thread_ts: threadTs, + text: 'Session ready. Send your first message here to start.', + }); +} diff --git a/src/providers/slack/config.ts b/src/providers/slack/config.ts new file mode 100644 index 0000000..6ce6cb7 --- /dev/null +++ b/src/providers/slack/config.ts @@ -0,0 +1,45 @@ +import { required } from '../../core/config'; + +function csv(key: string): string[] { + const val = process.env[key]; + if (!val) return []; + return val + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + +/** + * Slack adapter configuration. Loaded lazily so a deployment that + * disables Slack (ENABLED_PROVIDERS=discord) does not fail at startup + * for missing SLACK_BOT_TOKEN. + */ +export const slackConfig = { + get token() { + return required('SLACK_BOT_TOKEN'); + }, + get signingSecret() { + return required('SLACK_SIGNING_SECRET'); + }, + get teamId() { + return required('SLACK_TEAM_ID'); + }, + get appId() { + return required('SLACK_APP_ID'); + }, + get socketModeToken() { + return process.env.SLACK_SOCKET_MODE_TOKEN || ''; + }, + get allowedUserIds() { + return csv('SLACK_ALLOWED_USER_IDS'); + }, + get mentionUserId() { + return process.env.SLACK_MENTION_USER_ID || ''; + }, + get publicUrl() { + return process.env.SLACK_BOT_PUBLIC_URL || ''; + }, + get port() { + return parseInt(process.env.SLACK_PORT || '3000', 10); + }, +}; diff --git a/src/providers/slack/conversationsDb.ts b/src/providers/slack/conversationsDb.ts new file mode 100644 index 0000000..ebe6a1a --- /dev/null +++ b/src/providers/slack/conversationsDb.ts @@ -0,0 +1,58 @@ +import { db } from '../../core/db'; + +export interface SlackAgentConversation { + thread_ts: string; + channel_id: string; + agent_id: string; + owner_user_id: string | null; + session_id: string | null; + created_at: number; +} + +export const conversationDb = { + register( + threadTs: string, + channelId: string, + agentId: string, + ownerUserId: string | null, + ): void { + db.prepare( + `INSERT INTO slack_agent_conversations (thread_ts, channel_id, agent_id, owner_user_id) + VALUES (?, ?, ?, ?)`, + ).run(threadTs, channelId, agentId, ownerUserId); + }, + + get(threadTs: string): SlackAgentConversation | undefined { + return db + .prepare('SELECT * FROM slack_agent_conversations WHERE thread_ts = ?') + .get(threadTs) as SlackAgentConversation | undefined; + }, + + updateSession(threadTs: string, sessionId: string | null): void { + db.prepare( + 'UPDATE slack_agent_conversations SET session_id = ? WHERE thread_ts = ?', + ).run(sessionId, threadTs); + }, + + remove(threadTs: string): void { + db.prepare('DELETE FROM slack_agent_conversations WHERE thread_ts = ?').run(threadTs); + }, + + listByChannel(channelId: string): SlackAgentConversation[] { + return db + .prepare( + 'SELECT * FROM slack_agent_conversations WHERE channel_id = ? ORDER BY created_at DESC', + ) + .all(channelId) as SlackAgentConversation[]; + }, + + getByAgentId(agentId: string): SlackAgentConversation | undefined { + return db + .prepare('SELECT * FROM slack_agent_conversations WHERE agent_id = ? ORDER BY created_at DESC LIMIT 1') + .get(agentId) as SlackAgentConversation | undefined; + }, + + removeByChannel(channelId: string): void { + db.prepare('DELETE FROM slack_agent_conversations WHERE channel_id = ?').run(channelId); + }, +}; diff --git a/src/providers/slack/messageCreate.ts b/src/providers/slack/messageCreate.ts new file mode 100644 index 0000000..50e84f2 --- /dev/null +++ b/src/providers/slack/messageCreate.ts @@ -0,0 +1,54 @@ +import type { KernelContext, IncomingMessage } from '../../core/types'; +import { conversationDb } from './conversationsDb'; + +/** + * Factory that returns a handler for Slack `message` events. + * Only processes threaded replies to registered conversations. + */ +export function createMessageHandler(ctx: KernelContext) { + return async function handleMessage(args: { + event: Record; + }): Promise { + const event = args.event; + + // Ignore bot messages and empty messages + if (event['bot_id'] || !String(event['text'] ?? '').trim()) { + return; + } + + const threadTs = event['thread_ts'] as string | undefined; + const text = event['text'] as string; + const user = event['user'] as string | undefined; + const channel = event['channel'] as string; + const ts = event['ts'] as string; + + // Only process messages inside known threads + if (!threadTs) { + return; + } + + const convo = conversationDb.get(threadTs); + if (!convo) { + return; + } + + // Only the thread owner can interact + if (convo.owner_user_id && convo.owner_user_id !== user) { + return; + } + + const message: IncomingMessage = { + provider: 'slack', + messageId: ts, + channelId: threadTs, + authorId: user ?? '', + authorName: user ?? '', + content: text, + attachments: [], + isThread: true, + raw: event, + }; + + ctx.enqueue(message); + }; +} From b9bd3523ea289a592debe492d4b0ccb1ba5fe697 Mon Sep 17 00:00:00 2001 From: scriptease <1190368+scriptease@users.noreply.github.com> Date: Sat, 9 May 2026 18:26:51 +0200 Subject: [PATCH 18/31] fix(slack): map Unicode emoji to Slack names in react(); log react errors Slack reactions.add() requires emoji name strings (e.g. hourglass_flowing_sand) not Unicode characters. Added UNICODE_TO_SLACK mapping in the Slack adapter. Also added error logging to the queue react catch so failures are visible in logs instead of silently swallowed. Co-Authored-By: Claude Sonnet 4.6 --- src/core/queue.ts | 7 +++++-- src/providers/slack/adapter.ts | 12 ++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/core/queue.ts b/src/core/queue.ts index b036ef8..7db6870 100644 --- a/src/core/queue.ts +++ b/src/core/queue.ts @@ -112,8 +112,11 @@ export function createQueue(deps: QueueDeps) { if (provider.react) { try { reaction = await provider.react(messageTarget, '⏳'); - } catch { - // best-effort indicator; ignore failures + } catch (err) { + void deps.logger.error( + 'queue:react', + `provider=${message.provider} channel=${message.channelId} error=${err instanceof Error ? err.message : String(err)}`, + ); } } diff --git a/src/providers/slack/adapter.ts b/src/providers/slack/adapter.ts index 13f00bb..ffabc1d 100644 --- a/src/providers/slack/adapter.ts +++ b/src/providers/slack/adapter.ts @@ -1,4 +1,11 @@ import { App, ExpressReceiver, SocketModeReceiver } from '@slack/bolt'; + +const UNICODE_TO_SLACK: Record = { + '⏳': 'hourglass_flowing_sand', + '🎧': 'headphones', + 'βœ…': 'white_check_mark', + '❌': 'x', +}; import { WebClient } from '@slack/web-api'; import type { AgentChannelInfo, @@ -208,11 +215,12 @@ export class SlackProvider implements BridgeProvider { timestamp = target.messageId; } - await this.client.reactions.add({ channel, timestamp, name: emoji }); + const name = UNICODE_TO_SLACK[emoji] ?? emoji; + await this.client.reactions.add({ channel, timestamp, name }); return { remove: async () => { - await this.client!.reactions.remove({ channel, timestamp, name: emoji }); + await this.client!.reactions.remove({ channel, timestamp, name }); }, }; } From 0899871d269d6135591928b726b74efe7dd5fc27 Mon Sep 17 00:00:00 2001 From: scriptease <1190368+scriptease@users.noreply.github.com> Date: Sat, 9 May 2026 18:39:24 +0200 Subject: [PATCH 19/31] fix(slack): address code review findings - adapter.ts: move UNICODE_TO_SLACK after imports; validate user field in app_mention before use (guard against missing/non-string user); remove this.client non-null assertion in react() remove closure - conversationsDb.ts: INSERT OR IGNORE to prevent SQLITE_CONSTRAINT on duplicate thread_ts - commands/agents.ts: replace say:any with SayFn; blocks:object[] with KnownBlock[]; log conversations.list and top-level catch errors - commands/session.ts: replace say:any with SayFn - config.ts: guard port getter against NaN from parseInt Skipped: @slack/bolt v4 upgrade (major version, breaking changes) Co-Authored-By: Claude Sonnet 4.6 --- src/providers/slack/adapter.ts | 13 ++++++++--- src/providers/slack/commands/agents.ts | 30 ++++++++++++------------- src/providers/slack/commands/session.ts | 5 ++--- src/providers/slack/config.ts | 3 ++- src/providers/slack/conversationsDb.ts | 2 +- 5 files changed, 29 insertions(+), 24 deletions(-) diff --git a/src/providers/slack/adapter.ts b/src/providers/slack/adapter.ts index ffabc1d..72f0cf2 100644 --- a/src/providers/slack/adapter.ts +++ b/src/providers/slack/adapter.ts @@ -1,4 +1,5 @@ import { App, ExpressReceiver, SocketModeReceiver } from '@slack/bolt'; +import { WebClient } from '@slack/web-api'; const UNICODE_TO_SLACK: Record = { '⏳': 'hourglass_flowing_sand', @@ -6,7 +7,6 @@ const UNICODE_TO_SLACK: Record = { 'βœ…': 'white_check_mark', '❌': 'x', }; -import { WebClient } from '@slack/web-api'; import type { AgentChannelInfo, BridgeProvider, @@ -70,9 +70,15 @@ export class SlackProvider implements BridgeProvider { app.event('app_mention', async ({ event, say }) => { const eventData = event as unknown as Record; const text = String(eventData['text'] ?? ''); - const user = String(eventData['user'] ?? ''); + const rawUser = eventData['user']; const channel = String(eventData['channel'] ?? ''); + if (!rawUser || typeof rawUser !== 'string') { + await say('Could not identify the user. Please try again.'); + return; + } + const user = rawUser; + const allowed = slackConfig.allowedUserIds; if (allowed.length > 0 && !allowed.includes(user)) { return; @@ -220,7 +226,8 @@ export class SlackProvider implements BridgeProvider { return { remove: async () => { - await this.client!.reactions.remove({ channel, timestamp, name }); + if (!this.client) return; + await this.client.reactions.remove({ channel, timestamp, name }); }, }; } diff --git a/src/providers/slack/commands/agents.ts b/src/providers/slack/commands/agents.ts index 941b939..d8657cf 100644 --- a/src/providers/slack/commands/agents.ts +++ b/src/providers/slack/commands/agents.ts @@ -1,4 +1,4 @@ -import type { SlackCommandMiddlewareArgs } from '@slack/bolt'; +import type { SlackCommandMiddlewareArgs, SayFn, KnownBlock } from '@slack/bolt'; import { WebClient } from '@slack/web-api'; import { slackConfig } from '../config'; import { channelDb } from '../channelsDb'; @@ -23,18 +23,18 @@ export async function handle({ switch (subcommand?.toLowerCase()) { case 'new': - await handleNew(say as any, command.channel_id, args[0], command.user_id); + await handleNew(say, command.channel_id, args[0], command.user_id); break; case 'disconnect': - await handleDisconnect(say as any, command.channel_id, args[0]); + await handleDisconnect(say, command.channel_id, args[0]); break; case 'readonly': - await handleReadonly(say as any, command.channel_id, args[0], args[1]); + await handleReadonly(say, command.channel_id, args[0], args[1]); break; case 'list': case '': case undefined: - await handleList(say as any); + await handleList(say); break; default: await say( @@ -42,12 +42,12 @@ export async function handle({ ); } } catch (err) { + console.error('[slack/agents] command failed:', err); await say('Failed to execute agents command.'); } } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -async function handleList(say: any): Promise { +async function handleList(say: SayFn): Promise { const agents = await maestro.listAgents(); if (agents.length === 0) { @@ -55,7 +55,7 @@ async function handleList(say: any): Promise { return; } - const blocks: object[] = [ + const blocks: KnownBlock[] = [ { type: 'section', text: { type: 'mrkdwn', text: '*Available Maestro Agents:*' }, @@ -81,9 +81,8 @@ async function handleList(say: any): Promise { await say({ blocks }); } -// eslint-disable-next-line @typescript-eslint/no-explicit-any async function handleNew( - say: any, + say: SayFn, channelId: string, agentId: string | undefined, userId?: string, @@ -125,8 +124,9 @@ async function handleNew( newChannelId = existing.id; isArchived = existing.is_archived ?? false; } - } catch { - // ignore list error β€” will create below + } catch (err) { + console.error('[slack/agents] conversations.list failed:', err); + // ignore β€” will create below } if (!newChannelId) { @@ -170,9 +170,8 @@ async function handleNew( await say(`Created channel <#${newChannelId}> for *${agent.name}* (\`${agent.id}\`)`); } -// eslint-disable-next-line @typescript-eslint/no-explicit-any async function handleDisconnect( - say: any, + say: SayFn, channelId: string, agentId: string | undefined, ): Promise { @@ -201,9 +200,8 @@ async function handleDisconnect( } } -// eslint-disable-next-line @typescript-eslint/no-explicit-any async function handleReadonly( - say: any, + say: SayFn, channelId: string, agentId: string | undefined, mode: string | undefined, diff --git a/src/providers/slack/commands/session.ts b/src/providers/slack/commands/session.ts index d25712c..fe5465e 100644 --- a/src/providers/slack/commands/session.ts +++ b/src/providers/slack/commands/session.ts @@ -1,4 +1,4 @@ -import type { SlackCommandMiddlewareArgs } from '@slack/bolt'; +import type { SlackCommandMiddlewareArgs, SayFn } from '@slack/bolt'; import { WebClient } from '@slack/web-api'; import { slackConfig } from '../config'; import { channelDb } from '../channelsDb'; @@ -32,9 +32,8 @@ export async function handle({ } } -// eslint-disable-next-line @typescript-eslint/no-explicit-any async function handleNew( - say: any, + say: SayFn, channelId: string, sessionName: string | undefined, userId?: string, diff --git a/src/providers/slack/config.ts b/src/providers/slack/config.ts index 6ce6cb7..d1da87f 100644 --- a/src/providers/slack/config.ts +++ b/src/providers/slack/config.ts @@ -40,6 +40,7 @@ export const slackConfig = { return process.env.SLACK_BOT_PUBLIC_URL || ''; }, get port() { - return parseInt(process.env.SLACK_PORT || '3000', 10); + const parsed = parseInt(process.env.SLACK_PORT ?? '', 10); + return Number.isNaN(parsed) ? 3000 : parsed; }, }; diff --git a/src/providers/slack/conversationsDb.ts b/src/providers/slack/conversationsDb.ts index ebe6a1a..bc56d7c 100644 --- a/src/providers/slack/conversationsDb.ts +++ b/src/providers/slack/conversationsDb.ts @@ -17,7 +17,7 @@ export const conversationDb = { ownerUserId: string | null, ): void { db.prepare( - `INSERT INTO slack_agent_conversations (thread_ts, channel_id, agent_id, owner_user_id) + `INSERT OR IGNORE INTO slack_agent_conversations (thread_ts, channel_id, agent_id, owner_user_id) VALUES (?, ?, ?, ?)`, ).run(threadTs, channelId, agentId, ownerUserId); }, From 75a4fc33c4959712692ad987a182f61941e8eca8 Mon Sep 17 00:00:00 2001 From: scriptease <1190368+scriptease@users.noreply.github.com> Date: Sat, 9 May 2026 18:53:26 +0200 Subject: [PATCH 20/31] test(slack): add tests for conversationsDb, config, and react emoji mapping Co-Authored-By: Claude Sonnet 4.6 --- src/__tests__/slack-config.test.ts | 64 ++++++++++ src/__tests__/slack-conversationsDb.test.ts | 127 ++++++++++++++++++++ src/__tests__/slack-react.test.ts | 45 +++++++ 3 files changed, 236 insertions(+) create mode 100644 src/__tests__/slack-config.test.ts create mode 100644 src/__tests__/slack-conversationsDb.test.ts create mode 100644 src/__tests__/slack-react.test.ts diff --git a/src/__tests__/slack-config.test.ts b/src/__tests__/slack-config.test.ts new file mode 100644 index 0000000..acbeaad --- /dev/null +++ b/src/__tests__/slack-config.test.ts @@ -0,0 +1,64 @@ +import test, { beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; + +const SAVED: Record = {}; +const KEYS = ['SLACK_PORT', 'SLACK_ALLOWED_USER_IDS']; + +beforeEach(() => { + for (const k of KEYS) SAVED[k] = process.env[k]; +}); + +afterEach(() => { + for (const k of KEYS) { + if (SAVED[k] === undefined) delete process.env[k]; + else process.env[k] = SAVED[k]; + } +}); + +// Import the getter logic directly rather than the module singleton so we +// can exercise it without needing SLACK_BOT_TOKEN etc. at import time. +function getPort(): number { + const parsed = parseInt(process.env.SLACK_PORT ?? '', 10); + return Number.isNaN(parsed) ? 3000 : parsed; +} + +function getAllowedUserIds(): string[] { + const val = process.env.SLACK_ALLOWED_USER_IDS; + if (!val) return []; + return val.split(',').map((s) => s.trim()).filter((s) => s.length > 0); +} + +test('port defaults to 3000 when SLACK_PORT is unset', () => { + delete process.env.SLACK_PORT; + assert.equal(getPort(), 3000); +}); + +test('port parses a valid integer', () => { + process.env.SLACK_PORT = '4000'; + assert.equal(getPort(), 4000); +}); + +test('port falls back to 3000 for non-numeric value', () => { + process.env.SLACK_PORT = 'not-a-number'; + assert.equal(getPort(), 3000); +}); + +test('port falls back to 3000 for empty string', () => { + process.env.SLACK_PORT = ''; + assert.equal(getPort(), 3000); +}); + +test('allowedUserIds returns empty array when unset', () => { + delete process.env.SLACK_ALLOWED_USER_IDS; + assert.deepEqual(getAllowedUserIds(), []); +}); + +test('allowedUserIds parses comma-separated values', () => { + process.env.SLACK_ALLOWED_USER_IDS = 'U001,U002, U003 '; + assert.deepEqual(getAllowedUserIds(), ['U001', 'U002', 'U003']); +}); + +test('allowedUserIds filters empty entries', () => { + process.env.SLACK_ALLOWED_USER_IDS = 'U001,,U002'; + assert.deepEqual(getAllowedUserIds(), ['U001', 'U002']); +}); diff --git a/src/__tests__/slack-conversationsDb.test.ts b/src/__tests__/slack-conversationsDb.test.ts new file mode 100644 index 0000000..01009f9 --- /dev/null +++ b/src/__tests__/slack-conversationsDb.test.ts @@ -0,0 +1,127 @@ +import test, { afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import Database from 'better-sqlite3'; + +// Build an isolated in-memory DB with the slack_agent_conversations table +// and a minimal agent_channels stub so the shared `db` import isn't needed. +function makeDb() { + const db = new Database(':memory:'); + db.exec(` + CREATE TABLE slack_agent_conversations ( + thread_ts TEXT PRIMARY KEY, + channel_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + owner_user_id TEXT, + session_id TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) + ) + `); + return db; +} + +// Inline DAO matching conversationsDb.ts so we test logic without the +// shared singleton DB. +function makeConversationDb(db: ReturnType) { + return { + register(threadTs: string, channelId: string, agentId: string, ownerUserId: string | null) { + db.prepare( + `INSERT OR IGNORE INTO slack_agent_conversations (thread_ts, channel_id, agent_id, owner_user_id) + VALUES (?, ?, ?, ?)`, + ).run(threadTs, channelId, agentId, ownerUserId); + }, + get(threadTs: string) { + return db + .prepare('SELECT * FROM slack_agent_conversations WHERE thread_ts = ?') + .get(threadTs) as { thread_ts: string; channel_id: string; agent_id: string; owner_user_id: string | null; session_id: string | null } | undefined; + }, + updateSession(threadTs: string, sessionId: string | null) { + db.prepare( + 'UPDATE slack_agent_conversations SET session_id = ? WHERE thread_ts = ?', + ).run(sessionId, threadTs); + }, + remove(threadTs: string) { + db.prepare('DELETE FROM slack_agent_conversations WHERE thread_ts = ?').run(threadTs); + }, + listByChannel(channelId: string) { + return db + .prepare('SELECT * FROM slack_agent_conversations WHERE channel_id = ? ORDER BY created_at DESC') + .all(channelId) as { thread_ts: string }[]; + }, + }; +} + +let db: ReturnType; +let conversationDb: ReturnType; + +afterEach(() => { + db?.close(); +}); + +function setup() { + db = makeDb(); + conversationDb = makeConversationDb(db); +} + +test('register and get round-trip', () => { + setup(); + conversationDb.register('1234567890.123456', 'C001', 'agent-1', 'U001'); + const row = conversationDb.get('1234567890.123456'); + assert.ok(row); + assert.equal(row.channel_id, 'C001'); + assert.equal(row.agent_id, 'agent-1'); + assert.equal(row.owner_user_id, 'U001'); + assert.equal(row.session_id, null); +}); + +test('register is idempotent β€” duplicate thread_ts does not throw (INSERT OR IGNORE)', () => { + setup(); + conversationDb.register('1111111111.000001', 'C001', 'agent-1', 'U001'); + assert.doesNotThrow(() => { + conversationDb.register('1111111111.000001', 'C002', 'agent-2', 'U002'); + }); + // First registration wins + const row = conversationDb.get('1111111111.000001'); + assert.equal(row?.channel_id, 'C001'); +}); + +test('updateSession persists sessionId', () => { + setup(); + conversationDb.register('2222222222.000001', 'C001', 'agent-1', 'U001'); + conversationDb.updateSession('2222222222.000001', 'ses_abc123'); + const row = conversationDb.get('2222222222.000001'); + assert.equal(row?.session_id, 'ses_abc123'); +}); + +test('updateSession can clear sessionId to null', () => { + setup(); + conversationDb.register('3333333333.000001', 'C001', 'agent-1', 'U001'); + conversationDb.updateSession('3333333333.000001', 'ses_xyz'); + conversationDb.updateSession('3333333333.000001', null); + const row = conversationDb.get('3333333333.000001'); + assert.equal(row?.session_id, null); +}); + +test('remove deletes the row', () => { + setup(); + conversationDb.register('4444444444.000001', 'C001', 'agent-1', 'U001'); + conversationDb.remove('4444444444.000001'); + assert.equal(conversationDb.get('4444444444.000001'), undefined); +}); + +test('listByChannel returns all conversations for a channel', () => { + setup(); + conversationDb.register('5555555555.000001', 'C-CHAN', 'agent-1', 'U001'); + conversationDb.register('5555555555.000002', 'C-CHAN', 'agent-1', 'U002'); + conversationDb.register('5555555555.000003', 'C-OTHER', 'agent-2', 'U003'); + const rows = conversationDb.listByChannel('C-CHAN'); + assert.equal(rows.length, 2); +}); + +test('register with null owner does not throw', () => { + setup(); + assert.doesNotThrow(() => { + conversationDb.register('6666666666.000001', 'C001', 'agent-1', null); + }); + const row = conversationDb.get('6666666666.000001'); + assert.equal(row?.owner_user_id, null); +}); diff --git a/src/__tests__/slack-react.test.ts b/src/__tests__/slack-react.test.ts new file mode 100644 index 0000000..3415c76 --- /dev/null +++ b/src/__tests__/slack-react.test.ts @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +// Inline the mapping from adapter.ts to test it in isolation. +const UNICODE_TO_SLACK: Record = { + '⏳': 'hourglass_flowing_sand', + '🎧': 'headphones', + 'βœ…': 'white_check_mark', + '❌': 'x', +}; + +function toSlackEmojiName(emoji: string): string { + return UNICODE_TO_SLACK[emoji] ?? emoji; +} + +test('⏳ maps to hourglass_flowing_sand', () => { + assert.equal(toSlackEmojiName('⏳'), 'hourglass_flowing_sand'); +}); + +test('🎧 maps to headphones', () => { + assert.equal(toSlackEmojiName('🎧'), 'headphones'); +}); + +test('βœ… maps to white_check_mark', () => { + assert.equal(toSlackEmojiName('βœ…'), 'white_check_mark'); +}); + +test('❌ maps to x', () => { + assert.equal(toSlackEmojiName('❌'), 'x'); +}); + +test('unknown emoji passes through unchanged', () => { + assert.equal(toSlackEmojiName('πŸš€'), 'πŸš€'); +}); + +test('isThreadTs matches valid Slack timestamps', () => { + function isThreadTs(id: string): boolean { + return /^\d+\.\d+$/.test(id); + } + assert.ok(isThreadTs('1234567890.123456')); + assert.ok(isThreadTs('1777189034.828869')); + assert.equal(isThreadTs('C001'), false); + assert.equal(isThreadTs('not-a-ts'), false); + assert.equal(isThreadTs(''), false); +}); From 0e9be1c36b1773e306faa30b39607c1d698dff00 Mon Sep 17 00:00:00 2001 From: scriptease <1190368+scriptease@users.noreply.github.com> Date: Sat, 9 May 2026 23:11:26 +0200 Subject: [PATCH 21/31] fix(slack): address chris review findings - findOrCreateAgentChannel: nest unarchive in own try/catch so an unarchive failure clears channelId and falls through to create path instead of returning an archived/unusable channel - /agents readonly: validate mode strictly as on|off; return usage error on any other value instead of silently treating it as off - conversationsDb: extract createConversationDb(db) factory so tests can wire an in-memory DB without the shared singleton - Tests: convert all three Slack test files to import real production modules (slackConfig, toSlackEmojiName/isThreadTs, createConversationDb) - adapter.ts: export toSlackEmojiName and isThreadTs; move UNICODE_TO_SLACK constant after imports per convention - install.sh: add Slack module support to normalize_module, have_required detection, env prompting/generation, and config_complete validation Co-Authored-By: Claude Sonnet 4.6 --- install.sh | 81 +++++++++++++---- src/__tests__/slack-config.test.ts | 28 ++---- src/__tests__/slack-conversationsDb.test.ts | 40 +-------- src/__tests__/slack-react.test.ts | 16 +--- src/providers/slack/adapter.ts | 28 +++--- src/providers/slack/commands/agents.ts | 7 +- src/providers/slack/conversationsDb.ts | 99 +++++++++++---------- 7 files changed, 151 insertions(+), 148 deletions(-) diff --git a/install.sh b/install.sh index 3d0c19c..683ada5 100755 --- a/install.sh +++ b/install.sh @@ -88,7 +88,8 @@ normalize_module() { raw="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" case "$raw" in discord|'') echo "discord" ;; - *) die "Unsupported module/provider: $raw (supported today: discord)" ;; + slack) echo "slack" ;; + *) die "Unsupported module/provider: $raw (supported: discord, slack)" ;; esac } @@ -219,10 +220,20 @@ write_config() { local interactive=0 [ -r /dev/tty ] && interactive=1 local have_required=0 - if [ -n "${DISCORD_BOT_TOKEN:-}" ] \ - && [ -n "${DISCORD_CLIENT_ID:-}" ] \ - && [ -n "${DISCORD_GUILD_ID:-}" ]; then - have_required=1 + MODULE="$(normalize_module "$MODULE")" + if [ "$MODULE" = "slack" ]; then + if [ -n "${SLACK_BOT_TOKEN:-}" ] \ + && [ -n "${SLACK_SIGNING_SECRET:-}" ] \ + && [ -n "${SLACK_TEAM_ID:-}" ] \ + && [ -n "${SLACK_APP_ID:-}" ]; then + have_required=1 + fi + else + if [ -n "${DISCORD_BOT_TOKEN:-}" ] \ + && [ -n "${DISCORD_CLIENT_ID:-}" ] \ + && [ -n "${DISCORD_GUILD_ID:-}" ]; then + have_required=1 + fi fi if [ "$interactive" -eq 0 ] && [ "$have_required" -eq 0 ]; then @@ -234,17 +245,16 @@ write_config() { fi if [ "$interactive" -eq 1 ]; then - info "Configuring $env_file" - echo " Find these values in https://discord.com/developers/applications" + if [ "$MODULE" = "slack" ]; then + info "Configuring $env_file" + echo " Find these values in https://api.slack.com/apps" + else + info "Configuring $env_file" + echo " Find these values in https://discord.com/developers/applications" + fi else info "Writing config from environment to $env_file" fi - local token client_id guild_id allowed - MODULE="$(normalize_module "$MODULE")" - token="$(prompt_var DISCORD_BOT_TOKEN 'Discord bot token')" - client_id="$(prompt_var DISCORD_CLIENT_ID 'Discord application (client) ID')" - guild_id="$(prompt_var DISCORD_GUILD_ID 'Discord guild (server) ID')" - allowed="$(prompt_var DISCORD_ALLOWED_USER_IDS 'Allowed user IDs (comma-separated, optional)')" local tmp_env tmp_env="$(mktemp "${env_file}.XXXXXX")" @@ -253,11 +263,36 @@ write_config() { printf '# Generated by install.sh on %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" printf 'ENABLED_PROVIDERS=%s\n' "$MODULE" printf 'API_PORT=3457\n' - printf 'DISCORD_BOT_TOKEN=%s\n' "$token" - printf 'DISCORD_CLIENT_ID=%s\n' "$client_id" - printf 'DISCORD_GUILD_ID=%s\n' "$guild_id" - printf 'DISCORD_ALLOWED_USER_IDS=%s\n' "$allowed" - printf 'DISCORD_MENTION_USER_ID=\n' + if [ "$MODULE" = "slack" ]; then + local bot_token signing_secret team_id app_id socket_token slack_allowed mention_user slack_port + bot_token="$(prompt_var SLACK_BOT_TOKEN 'Slack bot token (xoxb-...)')" + signing_secret="$(prompt_var SLACK_SIGNING_SECRET 'Slack signing secret')" + team_id="$(prompt_var SLACK_TEAM_ID 'Slack team (workspace) ID')" + app_id="$(prompt_var SLACK_APP_ID 'Slack app ID')" + socket_token="$(prompt_var SLACK_SOCKET_MODE_TOKEN 'Slack Socket Mode app-level token (xapp-..., optional)')" + slack_allowed="$(prompt_var SLACK_ALLOWED_USER_IDS 'Allowed Slack user IDs (comma-separated, optional)')" + mention_user="$(prompt_var SLACK_MENTION_USER_ID 'Slack mention user ID (optional)')" + slack_port="$(prompt_var SLACK_PORT 'Slack HTTP port (optional, default 3000)')" + printf 'SLACK_BOT_TOKEN=%s\n' "$bot_token" + printf 'SLACK_SIGNING_SECRET=%s\n' "$signing_secret" + printf 'SLACK_TEAM_ID=%s\n' "$team_id" + printf 'SLACK_APP_ID=%s\n' "$app_id" + printf 'SLACK_SOCKET_MODE_TOKEN=%s\n' "$socket_token" + printf 'SLACK_ALLOWED_USER_IDS=%s\n' "$slack_allowed" + printf 'SLACK_MENTION_USER_ID=%s\n' "$mention_user" + printf 'SLACK_PORT=%s\n' "${slack_port:-3000}" + else + local token client_id guild_id allowed + token="$(prompt_var DISCORD_BOT_TOKEN 'Discord bot token')" + client_id="$(prompt_var DISCORD_CLIENT_ID 'Discord application (client) ID')" + guild_id="$(prompt_var DISCORD_GUILD_ID 'Discord guild (server) ID')" + allowed="$(prompt_var DISCORD_ALLOWED_USER_IDS 'Allowed user IDs (comma-separated, optional)')" + printf 'DISCORD_BOT_TOKEN=%s\n' "$token" + printf 'DISCORD_CLIENT_ID=%s\n' "$client_id" + printf 'DISCORD_GUILD_ID=%s\n' "$guild_id" + printf 'DISCORD_ALLOWED_USER_IDS=%s\n' "$allowed" + printf 'DISCORD_MENTION_USER_ID=\n' + fi printf 'FFMPEG_PATH=%s\n' "${VOICE_FFMPEG:-ffmpeg}" printf 'WHISPER_CLI_PATH=%s\n' "${VOICE_WHISPER:-whisper-cli}" printf 'WHISPER_MODEL_PATH=%s\n' "${VOICE_MODEL:-models/${DEFAULT_MODEL_NAME}}" @@ -270,7 +305,15 @@ write_config() { config_complete() { local file="$1" key value [ -f "$file" ] || return 1 - for key in DISCORD_BOT_TOKEN DISCORD_CLIENT_ID DISCORD_GUILD_ID; do + local enabled_module + enabled_module="$(sed -nE 's/^[[:space:]]*ENABLED_PROVIDERS[[:space:]]*=[[:space:]]*([^#[:space:]]+).*/\1/p' "$file" | head -n1)" + local required_keys + if [ "$enabled_module" = "slack" ]; then + required_keys="SLACK_BOT_TOKEN SLACK_SIGNING_SECRET SLACK_TEAM_ID SLACK_APP_ID" + else + required_keys="DISCORD_BOT_TOKEN DISCORD_CLIENT_ID DISCORD_GUILD_ID" + fi + for key in $required_keys; do value="$(sed -nE "s/^${key}=([^#[:space:]]+).*/\1/p" "$file" | head -n1)" [ -n "$value" ] || return 1 case "$value" in diff --git a/src/__tests__/slack-config.test.ts b/src/__tests__/slack-config.test.ts index acbeaad..02997ca 100644 --- a/src/__tests__/slack-config.test.ts +++ b/src/__tests__/slack-config.test.ts @@ -1,5 +1,6 @@ import test, { beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; +import { slackConfig } from '../providers/slack/config'; const SAVED: Record = {}; const KEYS = ['SLACK_PORT', 'SLACK_ALLOWED_USER_IDS']; @@ -15,50 +16,37 @@ afterEach(() => { } }); -// Import the getter logic directly rather than the module singleton so we -// can exercise it without needing SLACK_BOT_TOKEN etc. at import time. -function getPort(): number { - const parsed = parseInt(process.env.SLACK_PORT ?? '', 10); - return Number.isNaN(parsed) ? 3000 : parsed; -} - -function getAllowedUserIds(): string[] { - const val = process.env.SLACK_ALLOWED_USER_IDS; - if (!val) return []; - return val.split(',').map((s) => s.trim()).filter((s) => s.length > 0); -} - test('port defaults to 3000 when SLACK_PORT is unset', () => { delete process.env.SLACK_PORT; - assert.equal(getPort(), 3000); + assert.equal(slackConfig.port, 3000); }); test('port parses a valid integer', () => { process.env.SLACK_PORT = '4000'; - assert.equal(getPort(), 4000); + assert.equal(slackConfig.port, 4000); }); test('port falls back to 3000 for non-numeric value', () => { process.env.SLACK_PORT = 'not-a-number'; - assert.equal(getPort(), 3000); + assert.equal(slackConfig.port, 3000); }); test('port falls back to 3000 for empty string', () => { process.env.SLACK_PORT = ''; - assert.equal(getPort(), 3000); + assert.equal(slackConfig.port, 3000); }); test('allowedUserIds returns empty array when unset', () => { delete process.env.SLACK_ALLOWED_USER_IDS; - assert.deepEqual(getAllowedUserIds(), []); + assert.deepEqual(slackConfig.allowedUserIds, []); }); test('allowedUserIds parses comma-separated values', () => { process.env.SLACK_ALLOWED_USER_IDS = 'U001,U002, U003 '; - assert.deepEqual(getAllowedUserIds(), ['U001', 'U002', 'U003']); + assert.deepEqual(slackConfig.allowedUserIds, ['U001', 'U002', 'U003']); }); test('allowedUserIds filters empty entries', () => { process.env.SLACK_ALLOWED_USER_IDS = 'U001,,U002'; - assert.deepEqual(getAllowedUserIds(), ['U001', 'U002']); + assert.deepEqual(slackConfig.allowedUserIds, ['U001', 'U002']); }); diff --git a/src/__tests__/slack-conversationsDb.test.ts b/src/__tests__/slack-conversationsDb.test.ts index 01009f9..019a446 100644 --- a/src/__tests__/slack-conversationsDb.test.ts +++ b/src/__tests__/slack-conversationsDb.test.ts @@ -1,9 +1,8 @@ import test, { afterEach } from 'node:test'; import assert from 'node:assert/strict'; import Database from 'better-sqlite3'; +import { createConversationDb } from '../providers/slack/conversationsDb'; -// Build an isolated in-memory DB with the slack_agent_conversations table -// and a minimal agent_channels stub so the shared `db` import isn't needed. function makeDb() { const db = new Database(':memory:'); db.exec(` @@ -19,39 +18,8 @@ function makeDb() { return db; } -// Inline DAO matching conversationsDb.ts so we test logic without the -// shared singleton DB. -function makeConversationDb(db: ReturnType) { - return { - register(threadTs: string, channelId: string, agentId: string, ownerUserId: string | null) { - db.prepare( - `INSERT OR IGNORE INTO slack_agent_conversations (thread_ts, channel_id, agent_id, owner_user_id) - VALUES (?, ?, ?, ?)`, - ).run(threadTs, channelId, agentId, ownerUserId); - }, - get(threadTs: string) { - return db - .prepare('SELECT * FROM slack_agent_conversations WHERE thread_ts = ?') - .get(threadTs) as { thread_ts: string; channel_id: string; agent_id: string; owner_user_id: string | null; session_id: string | null } | undefined; - }, - updateSession(threadTs: string, sessionId: string | null) { - db.prepare( - 'UPDATE slack_agent_conversations SET session_id = ? WHERE thread_ts = ?', - ).run(sessionId, threadTs); - }, - remove(threadTs: string) { - db.prepare('DELETE FROM slack_agent_conversations WHERE thread_ts = ?').run(threadTs); - }, - listByChannel(channelId: string) { - return db - .prepare('SELECT * FROM slack_agent_conversations WHERE channel_id = ? ORDER BY created_at DESC') - .all(channelId) as { thread_ts: string }[]; - }, - }; -} - -let db: ReturnType; -let conversationDb: ReturnType; +let db: InstanceType; +let conversationDb: ReturnType; afterEach(() => { db?.close(); @@ -59,7 +27,7 @@ afterEach(() => { function setup() { db = makeDb(); - conversationDb = makeConversationDb(db); + conversationDb = createConversationDb(db); } test('register and get round-trip', () => { diff --git a/src/__tests__/slack-react.test.ts b/src/__tests__/slack-react.test.ts index 3415c76..271dc53 100644 --- a/src/__tests__/slack-react.test.ts +++ b/src/__tests__/slack-react.test.ts @@ -1,17 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; - -// Inline the mapping from adapter.ts to test it in isolation. -const UNICODE_TO_SLACK: Record = { - '⏳': 'hourglass_flowing_sand', - '🎧': 'headphones', - 'βœ…': 'white_check_mark', - '❌': 'x', -}; - -function toSlackEmojiName(emoji: string): string { - return UNICODE_TO_SLACK[emoji] ?? emoji; -} +import { toSlackEmojiName, isThreadTs } from '../providers/slack/adapter'; test('⏳ maps to hourglass_flowing_sand', () => { assert.equal(toSlackEmojiName('⏳'), 'hourglass_flowing_sand'); @@ -34,9 +23,6 @@ test('unknown emoji passes through unchanged', () => { }); test('isThreadTs matches valid Slack timestamps', () => { - function isThreadTs(id: string): boolean { - return /^\d+\.\d+$/.test(id); - } assert.ok(isThreadTs('1234567890.123456')); assert.ok(isThreadTs('1777189034.828869')); assert.equal(isThreadTs('C001'), false); diff --git a/src/providers/slack/adapter.ts b/src/providers/slack/adapter.ts index 72f0cf2..765d434 100644 --- a/src/providers/slack/adapter.ts +++ b/src/providers/slack/adapter.ts @@ -1,12 +1,5 @@ import { App, ExpressReceiver, SocketModeReceiver } from '@slack/bolt'; import { WebClient } from '@slack/web-api'; - -const UNICODE_TO_SLACK: Record = { - '⏳': 'hourglass_flowing_sand', - '🎧': 'headphones', - 'βœ…': 'white_check_mark', - '❌': 'x', -}; import type { AgentChannelInfo, BridgeProvider, @@ -28,8 +21,19 @@ import * as health from './commands/health'; import * as agents from './commands/agents'; import * as session from './commands/session'; +const UNICODE_TO_SLACK: Record = { + '⏳': 'hourglass_flowing_sand', + '🎧': 'headphones', + 'βœ…': 'white_check_mark', + '❌': 'x', +}; + +export function toSlackEmojiName(emoji: string): string { + return UNICODE_TO_SLACK[emoji] ?? emoji; +} + /** Matches a Slack message timestamp: digits.digits */ -function isThreadTs(id: string): boolean { +export function isThreadTs(id: string): boolean { return /^\d+\.\d+$/.test(id); } @@ -221,7 +225,7 @@ export class SlackProvider implements BridgeProvider { timestamp = target.messageId; } - const name = UNICODE_TO_SLACK[emoji] ?? emoji; + const name = toSlackEmojiName(emoji); await this.client.reactions.add({ channel, timestamp, name }); return { @@ -276,7 +280,11 @@ export class SlackProvider implements BridgeProvider { if (found?.id) { channelId = found.id; if (found.is_archived) { - await this.client.conversations.unarchive({ channel: channelId }); + try { + await this.client.conversations.unarchive({ channel: channelId }); + } catch { + channelId = undefined; + } } } } catch { diff --git a/src/providers/slack/commands/agents.ts b/src/providers/slack/commands/agents.ts index d8657cf..5fd9dcf 100644 --- a/src/providers/slack/commands/agents.ts +++ b/src/providers/slack/commands/agents.ts @@ -223,7 +223,12 @@ async function handleReadonly( return; } - const readOnly = mode.toLowerCase() === 'on'; + const normalized = mode.toLowerCase(); + if (normalized !== 'on' && normalized !== 'off') { + await say('Usage: `/agents readonly `'); + return; + } + const readOnly = normalized === 'on'; channelDb.setReadOnly(channelId, readOnly); const status = readOnly ? 'read-only' : 'read-write'; await say(`Agent *${existing.agent_name}* is now in ${status} mode for this channel.`); diff --git a/src/providers/slack/conversationsDb.ts b/src/providers/slack/conversationsDb.ts index bc56d7c..1ce5da8 100644 --- a/src/providers/slack/conversationsDb.ts +++ b/src/providers/slack/conversationsDb.ts @@ -1,3 +1,4 @@ +import type { Database } from 'better-sqlite3'; import { db } from '../../core/db'; export interface SlackAgentConversation { @@ -9,50 +10,54 @@ export interface SlackAgentConversation { created_at: number; } -export const conversationDb = { - register( - threadTs: string, - channelId: string, - agentId: string, - ownerUserId: string | null, - ): void { - db.prepare( - `INSERT OR IGNORE INTO slack_agent_conversations (thread_ts, channel_id, agent_id, owner_user_id) - VALUES (?, ?, ?, ?)`, - ).run(threadTs, channelId, agentId, ownerUserId); - }, - - get(threadTs: string): SlackAgentConversation | undefined { - return db - .prepare('SELECT * FROM slack_agent_conversations WHERE thread_ts = ?') - .get(threadTs) as SlackAgentConversation | undefined; - }, - - updateSession(threadTs: string, sessionId: string | null): void { - db.prepare( - 'UPDATE slack_agent_conversations SET session_id = ? WHERE thread_ts = ?', - ).run(sessionId, threadTs); - }, - - remove(threadTs: string): void { - db.prepare('DELETE FROM slack_agent_conversations WHERE thread_ts = ?').run(threadTs); - }, - - listByChannel(channelId: string): SlackAgentConversation[] { - return db - .prepare( - 'SELECT * FROM slack_agent_conversations WHERE channel_id = ? ORDER BY created_at DESC', - ) - .all(channelId) as SlackAgentConversation[]; - }, - - getByAgentId(agentId: string): SlackAgentConversation | undefined { - return db - .prepare('SELECT * FROM slack_agent_conversations WHERE agent_id = ? ORDER BY created_at DESC LIMIT 1') - .get(agentId) as SlackAgentConversation | undefined; - }, - - removeByChannel(channelId: string): void { - db.prepare('DELETE FROM slack_agent_conversations WHERE channel_id = ?').run(channelId); - }, -}; +export function createConversationDb(database: Database) { + return { + register( + threadTs: string, + channelId: string, + agentId: string, + ownerUserId: string | null, + ): void { + database.prepare( + `INSERT OR IGNORE INTO slack_agent_conversations (thread_ts, channel_id, agent_id, owner_user_id) + VALUES (?, ?, ?, ?)`, + ).run(threadTs, channelId, agentId, ownerUserId); + }, + + get(threadTs: string): SlackAgentConversation | undefined { + return database + .prepare('SELECT * FROM slack_agent_conversations WHERE thread_ts = ?') + .get(threadTs) as SlackAgentConversation | undefined; + }, + + updateSession(threadTs: string, sessionId: string | null): void { + database.prepare( + 'UPDATE slack_agent_conversations SET session_id = ? WHERE thread_ts = ?', + ).run(sessionId, threadTs); + }, + + remove(threadTs: string): void { + database.prepare('DELETE FROM slack_agent_conversations WHERE thread_ts = ?').run(threadTs); + }, + + listByChannel(channelId: string): SlackAgentConversation[] { + return database + .prepare( + 'SELECT * FROM slack_agent_conversations WHERE channel_id = ? ORDER BY created_at DESC', + ) + .all(channelId) as SlackAgentConversation[]; + }, + + getByAgentId(agentId: string): SlackAgentConversation | undefined { + return database + .prepare('SELECT * FROM slack_agent_conversations WHERE agent_id = ? ORDER BY created_at DESC LIMIT 1') + .get(agentId) as SlackAgentConversation | undefined; + }, + + removeByChannel(channelId: string): void { + database.prepare('DELETE FROM slack_agent_conversations WHERE channel_id = ?').run(channelId); + }, + }; +} + +export const conversationDb = createConversationDb(db); From e16deee53b24e45ded491f608af5ff9b701652c8 Mon Sep 17 00:00:00 2001 From: scriptease <1190368+scriptease@users.noreply.github.com> Date: Sun, 10 May 2026 08:32:50 +0200 Subject: [PATCH 22/31] fix(queue): show agent response even when session exits non-zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a SessionEnd hook fails (e.g. claude-mem), maestro-cli exits with code 1 and sets success:false, but the agent response is still present. Previously this discarded the response and surfaced the raw hook error to users. Now: if result.response is non-null, display it regardless of the success flag. When there is no response, sanitize hook-failure messages so the full shell script is not blasted at users β€” log the raw error instead. Co-Authored-By: Claude Sonnet 4.6 --- src/core/queue.ts | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/core/queue.ts b/src/core/queue.ts index 7db6870..0bfbd5a 100644 --- a/src/core/queue.ts +++ b/src/core/queue.ts @@ -182,21 +182,33 @@ export function createQueue(deps: QueueDeps) { // ignore cleanup failure } - if (!result.success || !result.response) { - const reason = result.error ?? 'The agent could not complete this request.'; + if (result.response) { + if (!result.success) { + // Agent produced a response but session ended with an error (e.g. a hook failure). + // Log it but don't surface the raw error to the user. + void deps.logger.error( + 'queue:agent-soft-failure', + `agent=${conv.agentId} session=${conv.sessionId ?? 'new'} channel=${message.channelId} error=${result.error}`, + ); + } + const parts = split(result.response); + for (const part of parts) { + await provider.send(target, { text: part }); + } + } else { + const rawReason = result.error ?? 'The agent could not complete this request.'; + // Strip verbose hook scripts from user-visible errors. + const reason = /hook\b.*\bfailed/i.test(rawReason) + ? 'The agent session ended with a hook error. Check logs for details.' + : rawReason; const hint = conv.readOnly ? '\n-# The agent is in **read-only** mode and cannot modify files.' : ''; void deps.logger.error( 'queue:agent-failure', - `agent=${conv.agentId} session=${conv.sessionId ?? 'new'} channel=${message.channelId} reason=${reason}`, + `agent=${conv.agentId} session=${conv.sessionId ?? 'new'} channel=${message.channelId} reason=${rawReason}`, ); await provider.send(target, { text: `⚠️ ${reason}${hint}` }); - } else { - const parts = split(result.response); - for (const part of parts) { - await provider.send(target, { text: part }); - } } const cost = (result.usage?.totalCostUsd ?? 0).toFixed(4); From 3dc9f68095fdd76acfc9024880446129a1c4082f Mon Sep 17 00:00:00 2001 From: scriptease <1190368+scriptease@users.noreply.github.com> Date: Sun, 10 May 2026 08:37:38 +0200 Subject: [PATCH 23/31] fix(queue): don't surface raw internal errors to chat clients Raw maestro-cli error strings (process exit codes, hook failure scripts, internal stack traces) were being forwarded verbatim to Discord/Slack. Replace with a generic user-facing message; log the full error detail for debugging. No provider-specific logic in this path. Co-Authored-By: Claude Sonnet 4.6 --- src/core/queue.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/core/queue.ts b/src/core/queue.ts index 0bfbd5a..cec183c 100644 --- a/src/core/queue.ts +++ b/src/core/queue.ts @@ -184,8 +184,6 @@ export function createQueue(deps: QueueDeps) { if (result.response) { if (!result.success) { - // Agent produced a response but session ended with an error (e.g. a hook failure). - // Log it but don't surface the raw error to the user. void deps.logger.error( 'queue:agent-soft-failure', `agent=${conv.agentId} session=${conv.sessionId ?? 'new'} channel=${message.channelId} error=${result.error}`, @@ -196,19 +194,17 @@ export function createQueue(deps: QueueDeps) { await provider.send(target, { text: part }); } } else { - const rawReason = result.error ?? 'The agent could not complete this request.'; - // Strip verbose hook scripts from user-visible errors. - const reason = /hook\b.*\bfailed/i.test(rawReason) - ? 'The agent session ended with a hook error. Check logs for details.' - : rawReason; const hint = conv.readOnly ? '\n-# The agent is in **read-only** mode and cannot modify files.' : ''; + const rawError = result.error ?? '(no error detail)'; void deps.logger.error( 'queue:agent-failure', - `agent=${conv.agentId} session=${conv.sessionId ?? 'new'} channel=${message.channelId} reason=${rawReason}`, + `agent=${conv.agentId} session=${conv.sessionId ?? 'new'} channel=${message.channelId} error=${rawError}`, ); - await provider.send(target, { text: `⚠️ ${reason}${hint}` }); + await provider.send(target, { + text: `⚠️ The agent could not complete this request.${hint}\n-# πŸ”§ ${rawError}`, + }); } const cost = (result.usage?.totalCostUsd ?? 0).toFixed(4); From dae0f925e41d23edbd9438e12d81535d5c892c16 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 10 May 2026 09:31:38 +0200 Subject: [PATCH 24/31] fix(queue,installer): hide internal errors and normalize quoted provider (#39) --- install.sh | 2 ++ src/core/queue.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/install.sh b/install.sh index 683ada5..265ea2d 100755 --- a/install.sh +++ b/install.sh @@ -307,6 +307,8 @@ config_complete() { [ -f "$file" ] || return 1 local enabled_module enabled_module="$(sed -nE 's/^[[:space:]]*ENABLED_PROVIDERS[[:space:]]*=[[:space:]]*([^#[:space:]]+).*/\1/p' "$file" | head -n1)" + enabled_module="${enabled_module#\"}"; enabled_module="${enabled_module%\"}" + enabled_module="${enabled_module#\'}"; enabled_module="${enabled_module%\'}" local required_keys if [ "$enabled_module" = "slack" ]; then required_keys="SLACK_BOT_TOKEN SLACK_SIGNING_SECRET SLACK_TEAM_ID SLACK_APP_ID" diff --git a/src/core/queue.ts b/src/core/queue.ts index cec183c..db5f2ef 100644 --- a/src/core/queue.ts +++ b/src/core/queue.ts @@ -203,7 +203,7 @@ export function createQueue(deps: QueueDeps) { `agent=${conv.agentId} session=${conv.sessionId ?? 'new'} channel=${message.channelId} error=${rawError}`, ); await provider.send(target, { - text: `⚠️ The agent could not complete this request.${hint}\n-# πŸ”§ ${rawError}`, + text: `⚠️ The agent could not complete this request.${hint}`, }); } @@ -227,7 +227,7 @@ export function createQueue(deps: QueueDeps) { `agent=${conv.agentId} session=${conv.sessionId ?? 'new'} channel=${message.channelId} error=${errMsg}`, ); await provider.send(target, { - text: `❌ Failed to get response from agent:\n\`\`\`\n${errMsg}\n\`\`\``, + text: '❌ Failed to get response from agent. Check relay logs for details.', }); } From 1978026a8dfc7804ea591ffdd16e644cedc25a2b Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 09:55:18 +0200 Subject: [PATCH 25/31] docs: add docs/slack.md and update provider-dev guide for built-in Slack - New docs/slack.md mirrors docs/discord.md: app setup, scopes, env-var table, Socket Mode vs webhook mode, slash commands, runtime behavior, storage, security, troubleshooting. - AGENTS-providers.md: Slack is now built-in; switch the worked example from Slack to a hypothetical Teams provider so the example doesn't pretend Slack is still unbuilt. README.md and AGENTS.md already link the new docs/slack.md as part of the merge from origin/main. --- AGENTS-providers.md | 21 ++++++----- docs/slack.md | 92 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 10 deletions(-) create mode 100644 docs/slack.md diff --git a/AGENTS-providers.md b/AGENTS-providers.md index 5792821..b6e5895 100644 --- a/AGENTS-providers.md +++ b/AGENTS-providers.md @@ -1,8 +1,8 @@ # Provider development guide -This document is the deep-dive companion to [`AGENTS.md`](AGENTS.md) (and [`docs/architecture.md`](docs/architecture.md)) for adding a new chat-platform provider to Maestro Relay. Everything below is what you'd need to know to ship a Slack, Teams, Matrix, etc. adapter without touching the kernel. +This document is the deep-dive companion to [`AGENTS.md`](AGENTS.md) (and [`docs/architecture.md`](docs/architecture.md)) for adding a new chat-platform provider to Maestro Relay. Discord and Slack are already built-in (see [`docs/discord.md`](docs/discord.md) and [`docs/slack.md`](docs/slack.md)); everything below is what you'd need to know to ship a Teams, Matrix, etc. adapter without touching the kernel. -If you're adding behavior to the existing Discord provider rather than building a new one, work in `src/providers/discord/` and consult [`docs/discord.md`](docs/discord.md) instead. +If you're adding behavior to an existing provider rather than building a new one, work in `src/providers/discord/` or `src/providers/slack/` and consult the matching `docs/.md` instead. ## The kernel/provider boundary @@ -107,12 +107,13 @@ Things to keep out of `src/core/`: SDK imports (`discord.js`, `@slack/bolt`, etc ### 1. Register the adapter -In `src/core/providers.ts`, add a `case` to `loadProvider`: +In `src/core/providers.ts`, add a `case` to `loadProvider` (alongside the existing `discord` and `slack` cases): ```ts -case 'slack': - const { SlackProvider } = await import('../providers/slack/adapter.js'); - return new SlackProvider(); +case 'teams': { + const { TeamsProvider } = await import('../providers/teams/adapter'); + return new TeamsProvider(); +} ``` This is the only kernel file the provider should touch. @@ -122,10 +123,10 @@ This is the only kernel file the provider should touch. Add a section to `.env.example`: ```env -# --- Slack provider (loaded only if 'slack' is in ENABLED_PROVIDERS) --- -SLACK_BOT_TOKEN=your_xoxb_token_here -SLACK_APP_TOKEN=your_xapp_token_here -SLACK_SIGNING_SECRET=your_signing_secret_here +# --- Teams provider (loaded only if 'teams' is in ENABLED_PROVIDERS) --- +TEAMS_BOT_TOKEN=your_token_here +TEAMS_APP_ID=your_app_id_here +TEAMS_TENANT_ID=your_tenant_id_here ``` Validate creds in `start(ctx)`, throwing a clear error if missing. Don't validate at module load β€” a disabled provider must not fail the bridge on missing env. diff --git a/docs/slack.md b/docs/slack.md new file mode 100644 index 0000000..961c17c --- /dev/null +++ b/docs/slack.md @@ -0,0 +1,92 @@ +# Slack provider + +The Slack provider lets Maestro Relay run inside a Slack workspace alongside or instead of Discord. This document covers everything Slack-specific: app creation, scopes, slash commands, and runtime behavior. For the kernel/provider boundary, see [architecture.md](architecture.md). + +The provider only loads if `slack` is in `ENABLED_PROVIDERS`. To run Slack and Discord simultaneously: `ENABLED_PROVIDERS=discord,slack`. + +## App setup + +1. Create an app at https://api.slack.com/apps (choose **From scratch**, pick a workspace). +2. **OAuth & Permissions β†’ Bot Token Scopes**, add: + - `app_mentions:read` + - `channels:history`, `channels:read`, `channels:join`, `channels:manage` + - `chat:write`, `chat:write.public` + - `commands` + - `reactions:write` + - `users:read` +3. **Event Subscriptions**: + - Subscribe to bot events: `app_mention`, `message.channels`. + - If you're using Socket Mode, enable it under **Settings β†’ Socket Mode** and generate an **App-Level Token** with `connections:write`. This becomes `SLACK_SOCKET_MODE_TOKEN`. + - Otherwise (webhook mode) point the **Request URL** at `https:///slack/events` and set `SLACK_BOT_PUBLIC_URL` accordingly. +4. **Slash Commands** β€” create one entry per command (`/health`, `/agents`, `/session`). Request URL is the same `…/slack/events` (webhook mode) or unused (Socket Mode). +5. Install the app to the workspace and copy: + - **Bot User OAuth Token** (`xoxb-…`) β†’ `SLACK_BOT_TOKEN` + - **Signing Secret** (Basic Information β†’ App Credentials) β†’ `SLACK_SIGNING_SECRET` + - Workspace **Team ID** (`T…`) β†’ `SLACK_TEAM_ID` + - **App ID** (`A…`) β†’ `SLACK_APP_ID` + +## Configuration + +Slack provider keys read from `.env`: + +| Key | Required | Purpose | +| ------------------------- | -------- | ---------------------------------------------------------------------------------------- | +| `SLACK_BOT_TOKEN` | yes | Bot User OAuth Token (`xoxb-…`) | +| `SLACK_SIGNING_SECRET` | yes | App-credentials signing secret (HTTP webhook verification) | +| `SLACK_TEAM_ID` | yes | Workspace ID (`T…`) | +| `SLACK_APP_ID` | yes | App ID (`A…`) | +| `SLACK_SOCKET_MODE_TOKEN` | no | App-level token (`xapp-…`); when set, Socket Mode is used instead of HTTP webhooks | +| `SLACK_BOT_PUBLIC_URL` | no | Public HTTPS URL for Bolt's `ExpressReceiver` (webhook mode only) | +| `SLACK_PORT` | no | HTTP port for `ExpressReceiver` (webhook mode only, default `3000`) | +| `SLACK_ALLOWED_USER_IDS` | no | Comma-separated Slack user IDs allowed to use slash commands; empty allows everyone | +| `SLACK_MENTION_USER_ID` | no | User ID to `@mention` when API callers pass `mention=true` | + +The Slack adapter loads its config lazily, so a deployment that disables Slack (`ENABLED_PROVIDERS=discord`) does **not** fail at startup for missing `SLACK_*` keys. + +### Choosing Socket Mode vs webhook mode + +- **Socket Mode** (`SLACK_SOCKET_MODE_TOKEN` set) β€” easiest for development and self-hosted production. The relay opens an outbound WebSocket to Slack; no public HTTPS endpoint required. +- **Webhook mode** (`SLACK_SOCKET_MODE_TOKEN` empty) β€” requires a publicly reachable HTTPS URL pointed at the relay (set `SLACK_BOT_PUBLIC_URL`, optionally override `SLACK_PORT`). Use this when you need a stateless deployment behind a load balancer. + +## Slash commands + +| Command | Description | +| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | +| `/health` | Verify the relay process is healthy | +| `/agents list` | Show all available Maestro agents | +| `/agents new ` | Create (or reopen) a dedicated public channel `#maestro-` and register it for the agent | +| `/agents disconnect [agent-id]` | (Run inside an agent channel) Unregister the binding and archive the channel | +| `/agents readonly ` | Toggle read-only mode for the current agent channel | +| `/session new [name]` | Post a parent message in the current agent channel and bind a new owner-scoped thread to the invoking user | + +> **Discord parity note:** the Slack `/agents readonly` command currently requires the agent ID as the first argument (Discord reads it from the bound channel). Track [TODO] for unification β€” until then, copy the ID from `/agents list`. + +## Runtime behavior + +- **Mentioning the bot** in a registered agent channel posts a new top-level message and binds it as a thread to the invoking user. Subsequent replies in that thread are forwarded to the agent. +- **`/session new`** does the same thing without requiring a mention; an optional name is shown in the parent message. +- **Owner-bound threads**: only the user who created the thread can drive the agent. Messages from other users are silently ignored. +- **Reactions**: `⏳` (`hourglass_flowing_sand`) while a message is queued. The Slack API requires emoji *names*, not Unicode characters; the adapter maps `⏳ 🎧 βœ… ❌` to the corresponding Slack names β€” pass any of them to `provider.react()` and the mapping happens automatically. +- **Typing indicator**: not exposed by Slack's Web API; `sendTyping` is a no-op on this provider. +- **Usage stats** are appended below each agent reply (tokens, cost, context %). +- **Channel naming**: when `/agents new` creates a channel, the name is derived as `maestro-` (lowercased, non-`a-z0-9-_` stripped, capped at 70 chars). Archived channels with the same name are unarchived; if unarchive fails, a `-` suffix is appended. + +## Storage + +- The shared `agent_channels` table stores Slack channel ↔ agent bindings with `provider='slack'`. +- `slack_agent_conversations` is a Slack-only thread registry keyed on `thread_ts`. It records `(channel_id, agent_id, owner_user_id, session_id)` and is dropped along with its parent channel when `/agents disconnect` runs. + +## Security + +- Slash command access can be locked down with `SLACK_ALLOWED_USER_IDS`. When empty, all workspace members may use slash commands. +- Threads created by mention or `/session new` are bound to a single owner; non-owner messages in the thread are ignored silently. +- The bot only auto-creates **public** channels (`is_private: false`). To use private channels, create them manually and run `/agents new` from inside. + +## Troubleshooting + +- **`/health` posts but slash commands return `dispatch_failed`** β†’ confirm the slash commands are registered in **Slack App β†’ Slash Commands** with the right Request URL (Socket Mode users can leave it blank). +- **`⏳` reaction never appears** β†’ check the bot has `reactions:write` and was reinstalled to the workspace after adding the scope. The adapter logs reaction failures via `logger.error('queue:react', …)`. +- **`signing_secret_missing` or HTTP 401 from Slack** β†’ fill in `SLACK_SIGNING_SECRET`. Required even in Socket Mode setups for parts of the Bolt SDK. +- **Bot is online but ignores thread replies** β†’ confirm the thread is in `slack_agent_conversations` (`/session new` or a mention created it) and that the message author matches `owner_user_id`. +- **Channel creation fails with `name_taken`** β†’ an archived channel exists with that name and unarchive failed. The adapter falls back to `-`; if that also fails, create the channel manually and re-run `/agents new`. +- **Slack rejects the emoji name** β†’ the adapter maps `⏳ 🎧 βœ… ❌`. Other Unicode emoji are passed through unchanged; if you call `provider.react()` from custom code with a Unicode emoji that's not in the map, add it to `UNICODE_TO_SLACK` in `src/providers/slack/adapter.ts`. From 1f2bdfa51e8ccc8684c892c7897acf1aa6126d71 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 09:57:34 +0200 Subject: [PATCH 26/31] fix(slack): align /agents readonly with Discord, log via logger.error - Drop the agent-id positional from /agents readonly. The bound agent is read from channelDb so the UX matches Discord (run inside the channel, just toggle on/off). - Replace console.error in slack/agents and slack/session catch blocks with logger.error so failures land in the same error log as the rest of the bridge. - Refresh /agents list help text and docs/slack.md to reflect the aligned readonly UX. --- docs/slack.md | 20 +++++++++--------- src/providers/slack/commands/agents.ts | 28 +++++++++---------------- src/providers/slack/commands/session.ts | 2 ++ 3 files changed, 22 insertions(+), 28 deletions(-) diff --git a/docs/slack.md b/docs/slack.md index 961c17c..e88acca 100644 --- a/docs/slack.md +++ b/docs/slack.md @@ -50,16 +50,16 @@ The Slack adapter loads its config lazily, so a deployment that disables Slack ( ## Slash commands -| Command | Description | -| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | -| `/health` | Verify the relay process is healthy | -| `/agents list` | Show all available Maestro agents | -| `/agents new ` | Create (or reopen) a dedicated public channel `#maestro-` and register it for the agent | -| `/agents disconnect [agent-id]` | (Run inside an agent channel) Unregister the binding and archive the channel | -| `/agents readonly ` | Toggle read-only mode for the current agent channel | -| `/session new [name]` | Post a parent message in the current agent channel and bind a new owner-scoped thread to the invoking user | - -> **Discord parity note:** the Slack `/agents readonly` command currently requires the agent ID as the first argument (Discord reads it from the bound channel). Track [TODO] for unification β€” until then, copy the ID from `/agents list`. +| Command | Description | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `/health` | Verify the relay process is healthy | +| `/agents list` | Show all available Maestro agents | +| `/agents new ` | Create (or reopen) a dedicated public channel `#maestro-` and register it for the agent | +| `/agents disconnect [agent-id]` | (Run inside an agent channel) Unregister the binding and archive the channel | +| `/agents readonly ` | (Run inside an agent channel) Toggle read-only mode for the bound agent | +| `/session new [name]` | Post a parent message in the current agent channel and bind a new owner-scoped thread to the invoking user | + +The Slack provider deliberately ships a smaller command surface than Discord β€” the playbook, gist, notes, and auto-run flows are Discord-only today. ## Runtime behavior diff --git a/src/providers/slack/commands/agents.ts b/src/providers/slack/commands/agents.ts index 5fd9dcf..1359e84 100644 --- a/src/providers/slack/commands/agents.ts +++ b/src/providers/slack/commands/agents.ts @@ -4,6 +4,7 @@ import { slackConfig } from '../config'; import { channelDb } from '../channelsDb'; import { conversationDb } from '../conversationsDb'; import { maestro } from '../../../core/maestro'; +import { logger } from '../../../core/logger'; export async function handle({ ack, @@ -29,7 +30,7 @@ export async function handle({ await handleDisconnect(say, command.channel_id, args[0]); break; case 'readonly': - await handleReadonly(say, command.channel_id, args[0], args[1]); + await handleReadonly(say, command.channel_id, args[0]); break; case 'list': case '': @@ -42,7 +43,7 @@ export async function handle({ ); } } catch (err) { - console.error('[slack/agents] command failed:', err); + void logger.error('slack/agents', err instanceof Error ? err.message : String(err)); await say('Failed to execute agents command.'); } } @@ -74,7 +75,7 @@ async function handleList(say: SayFn): Promise { type: 'section', text: { type: 'mrkdwn', - text: '*Register an agent:* `/agents new `\n*Unregister:* `/agents disconnect `\n*Toggle read-only:* `/agents readonly `', + text: '*Register an agent:* `/agents new `\n*Unregister (run inside the agent channel):* `/agents disconnect`\n*Toggle read-only (run inside the agent channel):* `/agents readonly `', }, }); @@ -125,7 +126,10 @@ async function handleNew( isArchived = existing.is_archived ?? false; } } catch (err) { - console.error('[slack/agents] conversations.list failed:', err); + void logger.error( + 'slack/agents:conversations.list', + err instanceof Error ? err.message : String(err), + ); // ignore β€” will create below } @@ -203,29 +207,17 @@ async function handleDisconnect( async function handleReadonly( say: SayFn, channelId: string, - agentId: string | undefined, mode: string | undefined, ): Promise { - if (!agentId || !mode) { - await say('Usage: `/agents readonly `'); - return; - } - const existing = channelDb.get(channelId); - if (!existing) { await say('No agent is registered in this channel.'); return; } - if (existing.agent_id !== agentId) { - await say(`Agent \`${agentId}\` is not registered in this channel.`); - return; - } - - const normalized = mode.toLowerCase(); + const normalized = mode?.toLowerCase(); if (normalized !== 'on' && normalized !== 'off') { - await say('Usage: `/agents readonly `'); + await say('Usage: `/agents readonly `'); return; } const readOnly = normalized === 'on'; diff --git a/src/providers/slack/commands/session.ts b/src/providers/slack/commands/session.ts index fe5465e..bfc4e0c 100644 --- a/src/providers/slack/commands/session.ts +++ b/src/providers/slack/commands/session.ts @@ -3,6 +3,7 @@ import { WebClient } from '@slack/web-api'; import { slackConfig } from '../config'; import { channelDb } from '../channelsDb'; import { conversationDb } from '../conversationsDb'; +import { logger } from '../../../core/logger'; export async function handle({ ack, @@ -28,6 +29,7 @@ export async function handle({ await say(`Unknown subcommand: \`${subcommand}\`. Try: \`new [session-name]\``); } } catch (err) { + void logger.error('slack/session', err instanceof Error ? err.message : String(err)); await say('Failed to execute session command.'); } } From d451d79a542956ad8150af400fe29afcf1f27acb Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 20:05:06 +0200 Subject: [PATCH 27/31] fix(slack): address PR #40 CodeRabbit review - Bump @slack/bolt from ^3.18.0 to ^4.6.0. The only API impact is KnownBlock no longer being re-exported from @slack/bolt; pull it from @slack/types directly (Bolt 4 still depends on it transitively). - Make Slack channel names collision-safe by appending an 8-char alphanumeric prefix of agent.id: maestro--. Two different agents whose names normalize to the same string no longer collapse onto the same Slack channel. - Hoist the channel-name + lookup-or-create logic into shared helpers (buildAgentChannelName, findOrCreateSlackChannel) so the HTTP-API push path (adapter.ts) and the /agents new slash command use identical behavior, including the unarchive-then-fallback timestamp dance that previously only existed in the slash command. - launchd plist: replace `:$PATH` with `${PATH:+:$PATH}` so an empty inherited PATH doesn't leave a trailing colon (which silently adds the cwd to lookup). - docs/slack.md: refresh the channel-naming bullet and the name_taken troubleshooting line so they describe the actual helper-driven behavior. - Add slack-channelName.test.ts covering collision avoidance, empty-name fallback, and the 80-char cap. 196/196 tests passing. --- docs/slack.md | 4 +- package-lock.json | 2081 ++++------------------- package.json | 2 +- src/__tests__/slack-channelName.test.ts | 45 + src/providers/slack/adapter.ts | 122 +- src/providers/slack/commands/agents.ts | 54 +- templates/sh.maestro.relay.plist | 2 +- 7 files changed, 459 insertions(+), 1851 deletions(-) create mode 100644 src/__tests__/slack-channelName.test.ts diff --git a/docs/slack.md b/docs/slack.md index e88acca..31978c4 100644 --- a/docs/slack.md +++ b/docs/slack.md @@ -69,7 +69,7 @@ The Slack provider deliberately ships a smaller command surface than Discord β€” - **Reactions**: `⏳` (`hourglass_flowing_sand`) while a message is queued. The Slack API requires emoji *names*, not Unicode characters; the adapter maps `⏳ 🎧 βœ… ❌` to the corresponding Slack names β€” pass any of them to `provider.react()` and the mapping happens automatically. - **Typing indicator**: not exposed by Slack's Web API; `sendTyping` is a no-op on this provider. - **Usage stats** are appended below each agent reply (tokens, cost, context %). -- **Channel naming**: when `/agents new` creates a channel, the name is derived as `maestro-` (lowercased, non-`a-z0-9-_` stripped, capped at 70 chars). Archived channels with the same name are unarchived; if unarchive fails, a `-` suffix is appended. +- **Channel naming**: agent channels are named `maestro--`, where `id-prefix` is the first 8 alphanumeric characters of the agent ID. The agent ID makes the name unique even when two different agents normalize to the same display name. The whole result is capped at 80 characters. Both `/agents new` and the HTTP-API auto-create path (`POST /api/send`) use the same helper. If the channel already exists but is archived, the adapter unarchives it; if unarchive fails, it falls back to creating a fresh channel with a `-` suffix appended to the base name. ## Storage @@ -88,5 +88,5 @@ The Slack provider deliberately ships a smaller command surface than Discord β€” - **`⏳` reaction never appears** β†’ check the bot has `reactions:write` and was reinstalled to the workspace after adding the scope. The adapter logs reaction failures via `logger.error('queue:react', …)`. - **`signing_secret_missing` or HTTP 401 from Slack** β†’ fill in `SLACK_SIGNING_SECRET`. Required even in Socket Mode setups for parts of the Bolt SDK. - **Bot is online but ignores thread replies** β†’ confirm the thread is in `slack_agent_conversations` (`/session new` or a mention created it) and that the message author matches `owner_user_id`. -- **Channel creation fails with `name_taken`** β†’ an archived channel exists with that name and unarchive failed. The adapter falls back to `-`; if that also fails, create the channel manually and re-run `/agents new`. +- **Channel creation fails with `name_taken`** β†’ an archived channel with the same `maestro-<…>-` name exists and unarchive failed. The adapter falls back to `-`; if that also fails (e.g. workspace channel limits, scope missing), create the channel manually and re-run `/agents new`. - **Slack rejects the emoji name** β†’ the adapter maps `⏳ 🎧 βœ… ❌`. Other Unicode emoji are passed through unchanged; if you call `provider.react()` from custom code with a Unicode emoji that's not in the map, add it to `UNICODE_TO_SLACK` in `src/providers/slack/adapter.ts`. diff --git a/package-lock.json b/package-lock.json index 72499f0..1ae6d49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.3", "license": "MIT", "dependencies": { - "@slack/bolt": "^3.18.0", + "@slack/bolt": "^4.6.0", "better-sqlite3": "^12.8.0", "discord.js": "^14.0.0", "dotenv": "^16.0.0" @@ -791,29 +791,28 @@ } }, "node_modules/@slack/bolt": { - "version": "3.22.0", - "resolved": "https://registry.npmjs.org/@slack/bolt/-/bolt-3.22.0.tgz", - "integrity": "sha512-iKDqGPEJDnrVwxSVlFW6OKTkijd7s4qLBeSufoBsTM0reTyfdp/5izIQVkxNfzjHi3o6qjdYbRXkYad5HBsBog==", - "license": "MIT", - "dependencies": { - "@slack/logger": "^4.0.0", - "@slack/oauth": "^2.6.3", - "@slack/socket-mode": "^1.3.6", - "@slack/types": "^2.13.0", - "@slack/web-api": "^6.13.0", - "@types/express": "^4.16.1", - "@types/promise.allsettled": "^1.0.3", - "@types/tsscmp": "^1.0.0", - "axios": "^1.7.4", - "express": "^4.21.0", + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/@slack/bolt/-/bolt-4.7.2.tgz", + "integrity": "sha512-ALHtaS2iaP2WAWgX08yXsoCxEDitC6AqZs26ot6smXJQzBFMM4slVP+w3blLwzUV551xZ/+9RlBmWHsZDJJ5HA==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/oauth": "^3.0.5", + "@slack/socket-mode": "^2.0.7", + "@slack/types": "^2.20.1", + "@slack/web-api": "^7.15.1", + "axios": "^1.12.0", + "express": "^5.0.0", "path-to-regexp": "^8.1.0", - "promise.allsettled": "^1.0.2", - "raw-body": "^2.3.3", + "raw-body": "^3", "tsscmp": "^1.0.6" }, "engines": { - "node": ">=14.21.3", - "npm": ">=6.14.18" + "node": ">=18", + "npm": ">=8.6.0" + }, + "peerDependencies": { + "@types/express": "^5.0.0" } }, "node_modules/@slack/logger": { @@ -830,96 +829,38 @@ } }, "node_modules/@slack/oauth": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/@slack/oauth/-/oauth-2.6.3.tgz", - "integrity": "sha512-1amXs6xRkJpoH6zSgjVPgGEJXCibKNff9WNDijcejIuVy1HFAl1adh7lehaGNiHhTWfQkfKxBiF+BGn56kvoFw==", - "license": "MIT", - "dependencies": { - "@slack/logger": "^3.0.0", - "@slack/web-api": "^6.12.1", - "@types/jsonwebtoken": "^8.3.7", - "@types/node": ">=12", - "jsonwebtoken": "^9.0.0", - "lodash.isstring": "^4.0.1" - }, - "engines": { - "node": ">=12.13.0", - "npm": ">=6.12.0" - } - }, - "node_modules/@slack/oauth/node_modules/@slack/logger": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-3.0.0.tgz", - "integrity": "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@slack/oauth/-/oauth-3.0.5.tgz", + "integrity": "sha512-exqFQySKhNDptWYSWhvRUJ4/+ndu2gayIy7vg/JfmJq3wGtGdHk531P96fAZyBm5c1Le3yaPYqv92rL4COlU3A==", "license": "MIT", "dependencies": { - "@types/node": ">=12.0.0" + "@slack/logger": "^4.0.1", + "@slack/web-api": "^7.15.0", + "@types/jsonwebtoken": "^9", + "@types/node": ">=18", + "jsonwebtoken": "^9" }, "engines": { - "node": ">= 12.13.0", - "npm": ">= 6.12.0" + "node": ">=18", + "npm": ">=8.6.0" } }, "node_modules/@slack/socket-mode": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@slack/socket-mode/-/socket-mode-1.3.6.tgz", - "integrity": "sha512-G+im7OP7jVqHhiNSdHgv2VVrnN5U7KY845/5EZimZkrD4ZmtV0P3BiWkgeJhPtdLuM7C7i6+M6h6Bh+S4OOalA==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@slack/socket-mode/-/socket-mode-2.0.7.tgz", + "integrity": "sha512-qYy07je71WnEHgRwmw12DlAnZLi5HXmdlI2WUzUK2LH/rYXQpP6uEg462S5CwfE8FoCKUdIigHtYnOOfzZH1lQ==", "license": "MIT", "dependencies": { - "@slack/logger": "^3.0.0", - "@slack/web-api": "^6.12.1", - "@types/node": ">=12.0.0", - "@types/ws": "^7.4.7", + "@slack/logger": "^4.0.1", + "@slack/web-api": "^7.15.0", + "@types/node": ">=18", + "@types/ws": "^8", "eventemitter3": "^5", - "finity": "^0.5.4", - "ws": "^7.5.3" + "ws": "^8" }, "engines": { - "node": ">=12.13.0", - "npm": ">=6.12.0" - } - }, - "node_modules/@slack/socket-mode/node_modules/@slack/logger": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-3.0.0.tgz", - "integrity": "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA==", - "license": "MIT", - "dependencies": { - "@types/node": ">=12.0.0" - }, - "engines": { - "node": ">= 12.13.0", - "npm": ">= 6.12.0" - } - }, - "node_modules/@slack/socket-mode/node_modules/@types/ws": { - "version": "7.4.7", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz", - "integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@slack/socket-mode/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "license": "MIT", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "node": ">= 18", + "npm": ">= 8.6.0" } }, "node_modules/@slack/types": { @@ -933,47 +874,29 @@ } }, "node_modules/@slack/web-api": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-6.13.0.tgz", - "integrity": "sha512-dv65crIgdh9ZYHrevLU6XFHTQwTyDmNqEqzuIrV+Vqe/vgiG6w37oex5ePDU1RGm2IJ90H8iOvHFvzdEO/vB+g==", + "version": "7.15.2", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.15.2.tgz", + "integrity": "sha512-/m9qVFkiq85Oa/FSQwYIRDa/AO4qNYkDh4sRBK1WqEc2+RyG7w4tbU6rBIwUOcc/TmWOIr24Nraquxg7um5mYw==", "license": "MIT", "dependencies": { - "@slack/logger": "^3.0.0", - "@slack/types": "^2.11.0", - "@types/is-stream": "^1.1.0", - "@types/node": ">=12.0.0", - "axios": "^1.7.4", - "eventemitter3": "^3.1.0", - "form-data": "^2.5.0", + "@slack/logger": "^4.0.1", + "@slack/types": "^2.21.0", + "@types/node": ">=18", + "@types/retry": "0.12.0", + "axios": "^1.15.0", + "eventemitter3": "^5.0.1", + "form-data": "^4.0.4", "is-electron": "2.2.2", - "is-stream": "^1.1.0", - "p-queue": "^6.6.1", - "p-retry": "^4.0.0" - }, - "engines": { - "node": ">= 12.13.0", - "npm": ">= 6.12.0" - } - }, - "node_modules/@slack/web-api/node_modules/@slack/logger": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-3.0.0.tgz", - "integrity": "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA==", - "license": "MIT", - "dependencies": { - "@types/node": ">=12.0.0" + "is-stream": "^2", + "p-queue": "^6", + "p-retry": "^4", + "retry": "^0.13.1" }, "engines": { - "node": ">= 12.13.0", - "npm": ">= 6.12.0" + "node": ">= 18", + "npm": ">= 8.6.0" } }, - "node_modules/@slack/web-api/node_modules/eventemitter3": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", - "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", - "license": "MIT" - }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -1017,6 +940,7 @@ "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", "license": "MIT", + "peer": true, "dependencies": { "@types/connect": "*", "@types/node": "*" @@ -1027,6 +951,7 @@ "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } @@ -1046,22 +971,23 @@ "license": "MIT" }, "node_modules/@types/express": { - "version": "4.17.25", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", - "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "^1" + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" } }, "node_modules/@types/express-serve-static-core": { - "version": "4.19.8", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", - "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "@types/qs": "*", @@ -1073,16 +999,8 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "license": "MIT" - }, - "node_modules/@types/is-stream": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@types/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha512-jkZatu4QVbR60mpIzjINmtS1ZF4a/FqdTUTBeQDVOQ2PYyidtwFKr0B5G6ERukKwliq+7mIXvxyppwzG5EgRYg==", "license": "MIT", - "dependencies": { - "@types/node": "*" - } + "peer": true }, "node_modules/@types/json-schema": { "version": "7.0.15", @@ -1092,18 +1010,19 @@ "license": "MIT" }, "node_modules/@types/jsonwebtoken": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-8.5.9.tgz", - "integrity": "sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg==", + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", "license": "MIT", "dependencies": { + "@types/ms": "*", "@types/node": "*" } }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, "node_modules/@types/node": { @@ -1115,23 +1034,19 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/promise.allsettled": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/promise.allsettled/-/promise.allsettled-1.0.6.tgz", - "integrity": "sha512-wA0UT0HeT2fGHzIFV9kWpYz5mdoyLxKrTgMdZQM++5h6pYAFH73HXcQhefg24nD1yivUFEn5KU+EF4b+CXJ4Wg==", - "license": "MIT" - }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/retry": { "version": "0.12.0", @@ -1144,37 +1059,22 @@ "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } }, "node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "license": "MIT", - "dependencies": { - "@types/mime": "^1", "@types/node": "*" } }, - "node_modules/@types/tsscmp": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@types/tsscmp/-/tsscmp-1.0.2.tgz", - "integrity": "sha512-cy7BRSU8GYYgxjcx0Py+8lo5MthuDhlyu076KUcYzVNXL23luYgRHkMG2fIFEc6neckeh/ntP82mw+U4QjZq+g==", - "license": "MIT" - }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -1425,13 +1325,13 @@ } }, "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" }, "engines": { "node": ">= 0.6" @@ -1497,100 +1397,12 @@ "dev": true, "license": "MIT" }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/array.prototype.map": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/array.prototype.map/-/array.prototype.map-1.0.8.tgz", - "integrity": "sha512-YocPM7bYYu2hXGxWpb5vwZ8cMeudNHYtYBcUDY4Z1GWa53qcnQMWSl25jeBHNzitjl9HW2AWW4ro/S/nftUaOQ==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", - "es-array-method-boxes-properly": "^1.0.0", - "es-object-atoms": "^1.0.0", - "is-string": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/axios": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", @@ -1602,22 +1414,6 @@ "proxy-from-env": "^2.1.0" } }, - "node_modules/axios/node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1683,57 +1479,27 @@ } }, "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/body-parser/node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { - "node": ">=0.6" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/brace-expansion": { @@ -1795,24 +1561,6 @@ "node": ">= 0.8" } }, - "node_modules/call-bind": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", - "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "get-intrinsic": "^1.3.0", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1861,15 +1609,16 @@ } }, "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/content-type": { @@ -1891,10 +1640,13 @@ } }, "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } }, "node_modules/create-require": { "version": "1.1.1", @@ -1918,62 +1670,10 @@ "node": ">= 8" } }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" - } - }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2018,44 +1718,10 @@ "dev": true, "license": "MIT" }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", "engines": { "node": ">=0.4.0" @@ -2070,16 +1736,6 @@ "node": ">= 0.8" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2194,80 +1850,6 @@ "once": "^1.4.0" } }, - "node_modules/es-abstract": { - "version": "1.24.2", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", - "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.2.1", - "is-set": "^2.0.3", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.1", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.4", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.4", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "stop-iteration-iterator": "^1.1.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.19" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-array-method-boxes-properly": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", - "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", - "license": "MIT" - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2286,26 +1868,6 @@ "node": ">= 0.4" } }, - "node_modules/es-get-iterator": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", - "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "is-arguments": "^1.1.1", - "is-map": "^2.0.2", - "is-set": "^2.0.2", - "is-string": "^1.0.7", - "isarray": "^2.0.5", - "stop-iteration-iterator": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2333,23 +1895,6 @@ "node": ">= 0.4" } }, - "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/esbuild": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", @@ -2600,72 +2145,48 @@ } }, "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.3", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.14.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/express/node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2724,38 +2245,26 @@ "license": "MIT" }, "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2773,12 +2282,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/finity": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/finity/-/finity-0.5.4.tgz", - "integrity": "sha512-3l+5/1tuw616Lgb0QBimxfdd2TqaDGpfCBpfX6EqtFmqUV3FtQnVEX4Aa62DagYEqnsTIjZcTfbq9msDbXYgyA==", - "license": "MIT" - }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -2820,36 +2323,41 @@ } } }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { - "is-callable": "^1.2.7" + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 6" } }, - "node_modules/form-data": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", - "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.35", - "safe-buffer": "^5.2.1" + "mime-db": "1.52.0" }, "engines": { - "node": ">= 0.12" + "node": ">= 0.6" } }, "node_modules/forwarded": { @@ -2862,12 +2370,12 @@ } }, "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/fs-constants": { @@ -2900,44 +2408,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -2975,23 +2445,6 @@ "node": ">= 0.4" } }, - "node_modules/get-symbol-description": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-tsconfig": { "version": "4.13.6", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", @@ -3024,22 +2477,6 @@ "node": ">=10.13.0" } }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "license": "MIT", - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3052,45 +2489,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -3151,15 +2549,19 @@ } }, "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ieee754": { @@ -3214,20 +2616,6 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3237,388 +2625,53 @@ "node": ">= 0.10" } }, - "node_modules/is-arguments": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "node_modules/is-electron": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz", + "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" + "is-extglob": "^2.1.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, - "node_modules/is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", - "license": "MIT", - "dependencies": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-electron": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz", - "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-stream": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, "engines": { - "node": ">= 0.4" + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3626,28 +2679,6 @@ "dev": true, "license": "ISC" }, - "node_modules/iterate-iterator": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/iterate-iterator/-/iterate-iterator-1.0.2.tgz", - "integrity": "sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/iterate-value": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/iterate-value/-/iterate-value-1.0.2.tgz", - "integrity": "sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ==", - "license": "MIT", - "dependencies": { - "es-get-iterator": "^1.0.2", - "iterate-iterator": "^1.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -3829,63 +2860,49 @@ } }, "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" + "node": ">=18" }, - "engines": { - "node": ">=4" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "mime-db": "^1.54.0" }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/mimic-response": { @@ -3951,9 +2968,9 @@ "license": "MIT" }, "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -3983,35 +3000,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -4051,23 +3039,6 @@ "node": ">= 0.8.0" } }, - "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", - "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.6", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", @@ -4208,15 +3179,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -4270,26 +3232,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/promise.allsettled": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/promise.allsettled/-/promise.allsettled-1.0.7.tgz", - "integrity": "sha512-hezvKvQQmsFkOdrZfYxUxkyxl8mgFQeT259Ajj9PXdbg9VzBCWrItOev72JyWxkCD5VSSqAeHmlN3tWx4DlmsA==", - "license": "MIT", - "dependencies": { - "array.prototype.map": "^1.0.5", - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "get-intrinsic": "^1.2.1", - "iterate-value": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -4333,9 +3275,9 @@ } }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -4357,18 +3299,18 @@ } }, "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", + "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.10" } }, "node_modules/rc": { @@ -4400,48 +3342,6 @@ "node": ">= 6" } }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -4461,23 +3361,20 @@ "node": ">= 4" } }, - "node_modules/safe-array-concat": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", - "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "get-intrinsic": "^1.3.0", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" }, "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 18" } }, "node_modules/safe-buffer": { @@ -4500,39 +3397,6 @@ ], "license": "MIT" }, - "node_modules/safe-push-apply": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -4552,103 +3416,48 @@ } }, "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" }, "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" + "node": ">= 18" }, - "engines": { - "node": ">= 0.4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" }, "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" + "node": ">= 18" }, - "engines": { - "node": ">= 0.4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/setprototypeof": { @@ -4827,19 +3636,6 @@ "node": ">= 0.8" } }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -4849,62 +3645,6 @@ "safe-buffer": "~5.2.0" } }, - "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -5090,92 +3830,19 @@ } }, "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "license": "MIT", "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" }, "engines": { "node": ">= 0.6" } }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -5214,24 +3881,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/unbox-primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-bigints": "^1.0.2", - "has-symbols": "^1.1.0", - "which-boxed-primitive": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/undici": { "version": "6.21.3", "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", @@ -5272,15 +3921,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -5313,91 +3953,6 @@ "node": ">= 8" } }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "license": "MIT", - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-builtin-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "function.prototype.name": "^1.1.6", - "has-tostringtag": "^1.0.2", - "is-async-function": "^2.0.0", - "is-date-object": "^1.1.0", - "is-finalizationregistry": "^1.1.0", - "is-generator-function": "^1.0.10", - "is-regex": "^1.2.1", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.1.0", - "which-collection": "^1.0.2", - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "license": "MIT", - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index c6a0f14..aca53fc 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "author": "", "license": "MIT", "dependencies": { - "@slack/bolt": "^3.18.0", + "@slack/bolt": "^4.6.0", "better-sqlite3": "^12.8.0", "discord.js": "^14.0.0", "dotenv": "^16.0.0" diff --git a/src/__tests__/slack-channelName.test.ts b/src/__tests__/slack-channelName.test.ts new file mode 100644 index 0000000..7fd9d3e --- /dev/null +++ b/src/__tests__/slack-channelName.test.ts @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildAgentChannelName } from '../providers/slack/adapter'; + +test('builds maestro-- from a clean name', () => { + const out = buildAgentChannelName({ id: 'abcd1234efgh', name: 'My Agent' }); + assert.equal(out, 'maestro-my-agent-abcd1234'); +}); + +test('lowercases, strips disallowed characters, and collapses dashes', () => { + const out = buildAgentChannelName({ id: 'abcd1234', name: 'Foo!! Bar??' }); + assert.equal(out, 'maestro-foo-bar-abcd1234'); +}); + +test('different agents with the same normalized name get different channel names', () => { + const a = buildAgentChannelName({ id: 'abcd1234', name: 'My Agent' }); + const b = buildAgentChannelName({ id: 'wxyz9876', name: 'My Agent' }); + assert.notEqual(a, b); + assert.equal(a, 'maestro-my-agent-abcd1234'); + assert.equal(b, 'maestro-my-agent-wxyz9876'); +}); + +test('falls back to "agent" when the sanitized name is empty', () => { + const out = buildAgentChannelName({ id: 'abcd1234', name: '!!!' }); + assert.equal(out, 'maestro-agent-abcd1234'); +}); + +test('caps overall length at 80 characters', () => { + const out = buildAgentChannelName({ + id: 'abcd1234', + name: 'a'.repeat(200), + }); + assert.ok(out.length <= 80, `expected <=80, got ${out.length}`); + assert.match(out, /^maestro-/); +}); + +test('strips leading and trailing dashes from the sanitized name', () => { + const out = buildAgentChannelName({ id: 'abcd1234', name: '!!!agent!!!' }); + assert.equal(out, 'maestro-agent-abcd1234'); +}); + +test('handles agent.id with non-alphanumeric characters in the suffix', () => { + const out = buildAgentChannelName({ id: 'a-b-c-d-1-2-3-4-5', name: 'My Agent' }); + assert.equal(out, 'maestro-my-agent-abcd1234'); +}); diff --git a/src/providers/slack/adapter.ts b/src/providers/slack/adapter.ts index 765d434..86c2cac 100644 --- a/src/providers/slack/adapter.ts +++ b/src/providers/slack/adapter.ts @@ -37,6 +37,88 @@ export function isThreadTs(id: string): boolean { return /^\d+\.\d+$/.test(id); } +/** + * Build a Slack channel name for an agent. + * + * Format: `maestro--` capped at 80 chars. + * The id-prefix (8 alphanumeric chars from agent.id) is what makes the + * name unique β€” without it, two agents whose names normalize to the + * same string would collapse to the same channel. + */ +export function buildAgentChannelName(agent: { id: string; name: string }): string { + const sanitizedName = agent.name + .toLowerCase() + .replace(/[^a-z0-9-_]/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + .substring(0, 60); + const baseName = sanitizedName || 'agent'; + const idPrefix = agent.id.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 8); + const suffix = idPrefix ? `-${idPrefix}` : ''; + return `maestro-${baseName}${suffix}`.slice(0, 80); +} + +/** + * Look up an existing Slack channel for an agent or create a fresh one. + * Returns `{ channelId, isNew }`. If the channel exists but is archived, + * tries to unarchive; if that fails, creates a new channel with a + * `-` suffix to avoid `name_taken`. + */ +export async function findOrCreateSlackChannel( + client: WebClient, + agent: { id: string; name: string }, +): Promise<{ channelId: string; isNew: boolean }> { + const channelName = buildAgentChannelName(agent); + + let channelId: string | undefined; + let isNew = false; + let isArchived = false; + + try { + const listRes = await client.conversations.list({ + exclude_archived: false, + types: 'public_channel', + limit: 1000, + }); + const found = listRes.channels?.find((ch) => ch.name === channelName); + if (found?.id) { + channelId = found.id; + isArchived = !!found.is_archived; + } + } catch { + // ignore β€” will create below + } + + if (channelId && isArchived) { + try { + await client.conversations.unarchive({ channel: channelId }); + } catch { + // Unarchive failed (e.g. permissions, channel locked). Fall back + // to a fresh timestamped channel β€” same trick the slash command + // uses, mirrored here so HTTP-API-driven flows behave the same. + const fallbackName = `${channelName}-${Date.now().toString().slice(-6)}`.slice(0, 80); + const res = await client.conversations.create({ + name: fallbackName, + is_private: false, + }); + if (!res.channel?.id) { + throw new Error(`Failed to create Slack channel for agent ${agent.id}`); + } + channelId = res.channel.id; + isNew = true; + } + } else if (!channelId) { + const res = await client.conversations.create({ name: channelName, is_private: false }); + if (!res.channel?.id) { + throw new Error(`Failed to create Slack channel for agent ${agent.id}`); + } + channelId = res.channel.id; + isNew = true; + } + + return { channelId: channelId!, isNew }; +} + export class SlackProvider implements BridgeProvider { readonly name = 'slack'; private app: App | null = null; @@ -261,45 +343,7 @@ export class SlackProvider implements BridgeProvider { const agent = allAgents.find((a) => a.id === agentId); if (!agent) throw new Error(`Agent not found: ${agentId}`); - const sanitizedName = agent.name - .toLowerCase() - .replace(/[^a-z0-9-_]/g, '-') - .replace(/-+/g, '-') - .substring(0, 70); - const channelName = `maestro-${sanitizedName}`; - - let channelId: string | undefined; - - try { - const listRes = await this.client.conversations.list({ - exclude_archived: false, - types: 'public_channel', - limit: 1000, - }); - const found = listRes.channels?.find((ch) => ch.name === channelName); - if (found?.id) { - channelId = found.id; - if (found.is_archived) { - try { - await this.client.conversations.unarchive({ channel: channelId }); - } catch { - channelId = undefined; - } - } - } - } catch { - // ignore β€” will create below - } - - if (!channelId) { - const res = await this.client.conversations.create({ - name: channelName, - is_private: false, - }); - if (!res.channel?.id) throw new Error(`Failed to create Slack channel for agent ${agentId}`); - channelId = res.channel.id; - } - + const { channelId } = await findOrCreateSlackChannel(this.client, agent); channelDb.register(channelId, agent.id, agent.name); return { channelId, agentId: agent.id, agentName: agent.name }; })(); diff --git a/src/providers/slack/commands/agents.ts b/src/providers/slack/commands/agents.ts index 1359e84..bec2997 100644 --- a/src/providers/slack/commands/agents.ts +++ b/src/providers/slack/commands/agents.ts @@ -1,10 +1,12 @@ -import type { SlackCommandMiddlewareArgs, SayFn, KnownBlock } from '@slack/bolt'; +import type { SlackCommandMiddlewareArgs, SayFn } from '@slack/bolt'; +import type { KnownBlock } from '@slack/types'; import { WebClient } from '@slack/web-api'; import { slackConfig } from '../config'; import { channelDb } from '../channelsDb'; import { conversationDb } from '../conversationsDb'; import { maestro } from '../../../core/maestro'; import { logger } from '../../../core/logger'; +import { findOrCreateSlackChannel } from '../adapter'; export async function handle({ ack, @@ -104,56 +106,18 @@ async function handleNew( } const client = new WebClient(slackConfig.token); - const sanitizedName = agent.name - .toLowerCase() - .replace(/[^a-z0-9-_]/g, '-') - .replace(/-+/g, '-') - .substring(0, 70); - const channelName = `maestro-${sanitizedName}`; - - let newChannelId: string | undefined; - let isArchived = false; + let newChannelId: string; try { - const listRes = await client.conversations.list({ - exclude_archived: false, - types: 'public_channel', - limit: 1000, - }); - const existing = listRes.channels?.find((ch) => ch.name === channelName); - if (existing?.id) { - newChannelId = existing.id; - isArchived = existing.is_archived ?? false; - } + const result = await findOrCreateSlackChannel(client, agent); + newChannelId = result.channelId; } catch (err) { void logger.error( - 'slack/agents:conversations.list', + 'slack/agents:findOrCreate', err instanceof Error ? err.message : String(err), ); - // ignore β€” will create below - } - - if (!newChannelId) { - const res = await client.conversations.create({ name: channelName, is_private: false }); - if (!res.channel?.id) { - await say('Failed to create channel for agent.'); - return; - } - newChannelId = res.channel.id; - } - - if (isArchived) { - try { - await client.conversations.unarchive({ channel: newChannelId }); - } catch { - const fallbackName = `${channelName}-${Date.now().toString().slice(-6)}`.substring(0, 80); - const res = await client.conversations.create({ name: fallbackName, is_private: false }); - if (!res.channel?.id) { - await say('Failed to create channel for agent.'); - return; - } - newChannelId = res.channel.id; - } + await say('Failed to create channel for agent.'); + return; } if (userId) { diff --git a/templates/sh.maestro.relay.plist b/templates/sh.maestro.relay.plist index 6703148..f436629 100644 --- a/templates/sh.maestro.relay.plist +++ b/templates/sh.maestro.relay.plist @@ -10,7 +10,7 @@ /bin/bash -c - set -a; . "@CONFIG_DIR@/.env"; set +a; export PATH="/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:$PATH"; exec "@NODE_BIN@" "@INSTALL_DIR@/dist/index.js" + set -a; . "@CONFIG_DIR@/.env"; set +a; export PATH="/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin${PATH:+:$PATH}"; exec "@NODE_BIN@" "@INSTALL_DIR@/dist/index.js" RunAtLoad From 4812716de4113452e8fa1cc59e0a0e8147d0e12d Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 21:27:09 +0200 Subject: [PATCH 28/31] fix(slack): address PR #40 Codex review - Paginate conversations.list when looking up an existing channel. Workspaces with >1000 public channels would previously miss matches on later pages and fail with name_taken on create. New findChannelByName helper walks response_metadata.next_cursor until it finds a hit or exhausts pages. - buildFallbackChannelName reserves the - suffix BEFORE concatenation, so an 80-char base no longer has its suffix sliced away (which would re-collide with the original name). - SLACK_PORT now rejects values outside 1..65535 (and falls back to 3000), preventing app.start() from crashing on negative or oversized ports passed via env. - Add tests for the three fixes: - findChannelByName paginates, returns null when absent, stops on empty next_cursor. - buildFallbackChannelName preserves the suffix at max length and trims base when needed. - SLACK_PORT rejects 0, -1, 65536, 70000 and accepts the boundaries 1 and 65535. 204/204 tests passing. --- src/__tests__/slack-channelName.test.ts | 83 ++++++++++++++++++++- src/__tests__/slack-config.test.ts | 21 ++++++ src/providers/slack/adapter.ts | 97 ++++++++++++++++++------- src/providers/slack/config.ts | 3 +- 4 files changed, 174 insertions(+), 30 deletions(-) diff --git a/src/__tests__/slack-channelName.test.ts b/src/__tests__/slack-channelName.test.ts index 7fd9d3e..f3b3d7e 100644 --- a/src/__tests__/slack-channelName.test.ts +++ b/src/__tests__/slack-channelName.test.ts @@ -1,6 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { buildAgentChannelName } from '../providers/slack/adapter'; +import { + buildAgentChannelName, + buildFallbackChannelName, + findChannelByName, +} from '../providers/slack/adapter'; test('builds maestro-- from a clean name', () => { const out = buildAgentChannelName({ id: 'abcd1234efgh', name: 'My Agent' }); @@ -43,3 +47,80 @@ test('handles agent.id with non-alphanumeric characters in the suffix', () => { const out = buildAgentChannelName({ id: 'a-b-c-d-1-2-3-4-5', name: 'My Agent' }); assert.equal(out, 'maestro-my-agent-abcd1234'); }); + +test('buildFallbackChannelName preserves the full - suffix even at max length', () => { + const base = 'a'.repeat(80); + const out = buildFallbackChannelName(base, 1234567890123); + assert.ok(out.length <= 80, `expected <=80, got ${out.length}`); + // Suffix is `-` + last 6 digits of the timestamp = 7 chars. + assert.match(out, /-890123$/); +}); + +test('buildFallbackChannelName trims base to make room for the suffix', () => { + const base = 'maestro-agent-abcdefgh'; + const out = buildFallbackChannelName(base, 1700000000123); + assert.equal(out, 'maestro-agent-abcdefgh-000123'); + assert.ok(out.length <= 80); +}); + +test('findChannelByName paginates until match found', async () => { + const pages: Array<{ + channels: Array<{ id: string; name: string; is_archived?: boolean }>; + response_metadata: { next_cursor?: string }; + }> = [ + { + channels: [{ id: 'C100', name: 'general' }], + response_metadata: { next_cursor: 'cursor-2' }, + }, + { + channels: [{ id: 'C200', name: 'random' }], + response_metadata: { next_cursor: 'cursor-3' }, + }, + { + channels: [{ id: 'C300', name: 'maestro-target-abcd1234', is_archived: true }], + response_metadata: { next_cursor: '' }, + }, + ]; + let calls = 0; + const list = async (args: { cursor?: string }) => { + calls++; + if (!args.cursor) return pages[0]; + if (args.cursor === 'cursor-2') return pages[1]; + if (args.cursor === 'cursor-3') return pages[2]; + throw new Error(`unexpected cursor: ${args.cursor}`); + }; + const result = await findChannelByName(list, 'maestro-target-abcd1234'); + assert.deepEqual(result, { id: 'C300', is_archived: true }); + assert.equal(calls, 3, 'should have walked all three pages'); +}); + +test('findChannelByName returns null when name is on no page', async () => { + const list = async (args: { cursor?: string }) => { + if (!args.cursor) { + return { + channels: [{ id: 'C1', name: 'a' }], + response_metadata: { next_cursor: 'next' }, + }; + } + return { + channels: [{ id: 'C2', name: 'b' }], + response_metadata: { next_cursor: '' }, + }; + }; + const result = await findChannelByName(list, 'maestro-missing'); + assert.equal(result, null); +}); + +test('findChannelByName stops on the first page when next_cursor is empty', async () => { + let calls = 0; + const list = async () => { + calls++; + return { + channels: [{ id: 'C1', name: 'maestro-x-abcd1234' }], + response_metadata: { next_cursor: '' }, + }; + }; + const result = await findChannelByName(list, 'maestro-x-abcd1234'); + assert.deepEqual(result, { id: 'C1', is_archived: false }); + assert.equal(calls, 1); +}); diff --git a/src/__tests__/slack-config.test.ts b/src/__tests__/slack-config.test.ts index 02997ca..25f33af 100644 --- a/src/__tests__/slack-config.test.ts +++ b/src/__tests__/slack-config.test.ts @@ -36,6 +36,27 @@ test('port falls back to 3000 for empty string', () => { assert.equal(slackConfig.port, 3000); }); +test('port rejects values below 1', () => { + process.env.SLACK_PORT = '0'; + assert.equal(slackConfig.port, 3000); + process.env.SLACK_PORT = '-1'; + assert.equal(slackConfig.port, 3000); +}); + +test('port rejects values above 65535', () => { + process.env.SLACK_PORT = '65536'; + assert.equal(slackConfig.port, 3000); + process.env.SLACK_PORT = '70000'; + assert.equal(slackConfig.port, 3000); +}); + +test('port accepts boundary values 1 and 65535', () => { + process.env.SLACK_PORT = '1'; + assert.equal(slackConfig.port, 1); + process.env.SLACK_PORT = '65535'; + assert.equal(slackConfig.port, 65535); +}); + test('allowedUserIds returns empty array when unset', () => { delete process.env.SLACK_ALLOWED_USER_IDS; assert.deepEqual(slackConfig.allowedUserIds, []); diff --git a/src/providers/slack/adapter.ts b/src/providers/slack/adapter.ts index 86c2cac..6e4d478 100644 --- a/src/providers/slack/adapter.ts +++ b/src/providers/slack/adapter.ts @@ -58,6 +58,49 @@ export function buildAgentChannelName(agent: { id: string; name: string }): stri return `maestro-${baseName}${suffix}`.slice(0, 80); } +/** + * Build a fallback channel name when unarchive fails. + * + * Reserves space for the timestamp suffix BEFORE concatenation so a + * full-length 80-char base doesn't have its suffix sliced away β€” that + * would re-collide with the original name and trigger another + * `name_taken`. + */ +export function buildFallbackChannelName(base: string, now: number = Date.now()): string { + const suffix = `-${now.toString().slice(-6)}`; + const maxBase = 80 - suffix.length; + return `${base.slice(0, maxBase)}${suffix}`; +} + +/** Slack `conversations.list` page shape we actually consume. */ +type ConversationsListPage = { + channels?: Array<{ id?: string; name?: string; is_archived?: boolean }>; + response_metadata?: { next_cursor?: string }; +}; +type ConversationsLister = (args: { cursor?: string }) => Promise; + +/** + * Walk every page of `conversations.list` looking for a channel with + * the given name. Workspaces with >1000 public channels would + * otherwise miss matches on later pages and surface as `name_taken` + * on create. + */ +export async function findChannelByName( + list: ConversationsLister, + name: string, +): Promise<{ id: string; is_archived: boolean } | null> { + let cursor: string | undefined; + do { + const res = await list({ cursor }); + const match = res.channels?.find((ch) => ch.name === name); + if (match?.id) { + return { id: match.id, is_archived: !!match.is_archived }; + } + cursor = res.response_metadata?.next_cursor || undefined; + } while (cursor); + return null; +} + /** * Look up an existing Slack channel for an agent or create a fresh one. * Returns `{ channelId, isNew }`. If the channel exists but is archived, @@ -70,33 +113,31 @@ export async function findOrCreateSlackChannel( ): Promise<{ channelId: string; isNew: boolean }> { const channelName = buildAgentChannelName(agent); - let channelId: string | undefined; - let isNew = false; - let isArchived = false; - + let existing: { id: string; is_archived: boolean } | null = null; try { - const listRes = await client.conversations.list({ - exclude_archived: false, - types: 'public_channel', - limit: 1000, - }); - const found = listRes.channels?.find((ch) => ch.name === channelName); - if (found?.id) { - channelId = found.id; - isArchived = !!found.is_archived; - } + existing = await findChannelByName( + (args) => + client.conversations.list({ + exclude_archived: false, + types: 'public_channel', + limit: 1000, + cursor: args.cursor, + }) as Promise, + channelName, + ); } catch { - // ignore β€” will create below + // ignore β€” will fall through to create } - if (channelId && isArchived) { + if (existing && existing.is_archived) { try { - await client.conversations.unarchive({ channel: channelId }); + await client.conversations.unarchive({ channel: existing.id }); + return { channelId: existing.id, isNew: false }; } catch { // Unarchive failed (e.g. permissions, channel locked). Fall back // to a fresh timestamped channel β€” same trick the slash command // uses, mirrored here so HTTP-API-driven flows behave the same. - const fallbackName = `${channelName}-${Date.now().toString().slice(-6)}`.slice(0, 80); + const fallbackName = buildFallbackChannelName(channelName); const res = await client.conversations.create({ name: fallbackName, is_private: false, @@ -104,19 +145,19 @@ export async function findOrCreateSlackChannel( if (!res.channel?.id) { throw new Error(`Failed to create Slack channel for agent ${agent.id}`); } - channelId = res.channel.id; - isNew = true; + return { channelId: res.channel.id, isNew: true }; } - } else if (!channelId) { - const res = await client.conversations.create({ name: channelName, is_private: false }); - if (!res.channel?.id) { - throw new Error(`Failed to create Slack channel for agent ${agent.id}`); - } - channelId = res.channel.id; - isNew = true; } - return { channelId: channelId!, isNew }; + if (existing) { + return { channelId: existing.id, isNew: false }; + } + + const res = await client.conversations.create({ name: channelName, is_private: false }); + if (!res.channel?.id) { + throw new Error(`Failed to create Slack channel for agent ${agent.id}`); + } + return { channelId: res.channel.id, isNew: true }; } export class SlackProvider implements BridgeProvider { diff --git a/src/providers/slack/config.ts b/src/providers/slack/config.ts index d1da87f..efcb80c 100644 --- a/src/providers/slack/config.ts +++ b/src/providers/slack/config.ts @@ -41,6 +41,7 @@ export const slackConfig = { }, get port() { const parsed = parseInt(process.env.SLACK_PORT ?? '', 10); - return Number.isNaN(parsed) ? 3000 : parsed; + if (Number.isNaN(parsed) || parsed < 1 || parsed > 65535) return 3000; + return parsed; }, }; From 85affab58b9c50aacf923fe515d25988f4458627 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 22:31:29 +0200 Subject: [PATCH 29/31] docs(slack): clarify mention-strip comment matches actual /g regex behavior CodeRabbit flagged that the comment "Strip bot mention" was misleading since /<@[^>]+>/g strips every Slack user mention, not just the bot's. The behavior is intentional (other users' <@U123> tokens would be opaque to the agent and Slack already notified them on the original message), so just rewrite the comment to describe what the code actually does. --- src/providers/slack/adapter.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/providers/slack/adapter.ts b/src/providers/slack/adapter.ts index 6e4d478..28ac202 100644 --- a/src/providers/slack/adapter.ts +++ b/src/providers/slack/adapter.ts @@ -217,7 +217,11 @@ export class SlackProvider implements BridgeProvider { return; } - // Strip bot mention from text + // Strip all Slack user mentions before forwarding to the agent. + // The bot's own mention is the trigger that brought us here, and + // other users' mentions would just surface as opaque <@U123> tokens + // to the agent β€” Slack still notifies those users via the original + // message, so dropping them from the agent-bound text is safe. const cleanText = text.replace(/<@[^>]+>/g, '').trim(); if (!cleanText) { await say('I received your mention, but no message. Please include a message.'); From bd09b7d6dc41b0ed436798e011e31a6b4973eccb Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 10 May 2026 22:37:57 +0200 Subject: [PATCH 30/31] fix(slack): close out remaining PR #40 follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four small cleanups so this PR ships Slack support without leaving known gaps for follow-up work: - Bump @types/node from ^20 to ^22 to match the Node 22+ runtime requirement (engines, install.sh, README). - SlackProvider.send() and .react() now log slack/{send,react}: orphan-thread before throwing when a target thread_ts has no row in slack_agent_conversations. The kernel still surfaces the generic ❌, but operators can grep for the specific orphan event without trawling through queue:send-error noise. - Align Slack /agents new lookup with Discord's: exact id, id-prefix, or exact name. Previously Slack only accepted exact id or case-insensitive name; matching Discord means an agent ID/name that works in one chat platform works in the other. - Add slack-findOrCreateSlackChannel.test.ts covering the seven orchestration paths: existing-not-archived, existing-archived (unarchive succeeds), existing-archived (unarchive fails β†’ timestamped fallback create), missing channel (primary create), list throws (still creates), create returns no id (throws), pagination across pages. 211/211 tests passing. --- package-lock.json | 8 +- package.json | 2 +- .../slack-findOrCreateSlackChannel.test.ts | 181 ++++++++++++++++++ src/providers/slack/adapter.ts | 14 +- src/providers/slack/commands/agents.ts | 10 +- 5 files changed, 204 insertions(+), 11 deletions(-) create mode 100644 src/__tests__/slack-findOrCreateSlackChannel.test.ts diff --git a/package-lock.json b/package-lock.json index 1ae6d49..6c17060 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@types/better-sqlite3": "^7.6.13", - "@types/node": "^20.0.0", + "@types/node": "^22.0.0", "eslint": "^10.2.0", "eslint-config-prettier": "^10.1.8", "prettier": "^3.8.2", @@ -1026,9 +1026,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.35", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", - "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", + "version": "22.19.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.18.tgz", + "integrity": "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" diff --git a/package.json b/package.json index aca53fc..958f831 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@types/better-sqlite3": "^7.6.13", - "@types/node": "^20.0.0", + "@types/node": "^22.0.0", "eslint": "^10.2.0", "eslint-config-prettier": "^10.1.8", "prettier": "^3.8.2", diff --git a/src/__tests__/slack-findOrCreateSlackChannel.test.ts b/src/__tests__/slack-findOrCreateSlackChannel.test.ts new file mode 100644 index 0000000..06608f4 --- /dev/null +++ b/src/__tests__/slack-findOrCreateSlackChannel.test.ts @@ -0,0 +1,181 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { WebClient } from '@slack/web-api'; +import { findOrCreateSlackChannel } from '../providers/slack/adapter'; + +type ListArgs = { cursor?: string; limit?: number; types?: string; exclude_archived?: boolean }; +type ListResult = { + channels: Array<{ id: string; name: string; is_archived?: boolean }>; + response_metadata: { next_cursor: string }; +}; +type CreateResult = { channel: { id: string } | undefined }; + +interface FakeClient { + conversations: { + list: (args: ListArgs) => Promise; + unarchive: (args: { channel: string }) => Promise; + create: (args: { name: string; is_private: boolean }) => Promise; + }; +} + +function makeClient(opts: { + list: (args: ListArgs) => Promise; + unarchive?: (args: { channel: string }) => Promise; + create?: (args: { name: string; is_private: boolean }) => Promise; +}): { client: WebClient; calls: { list: number; unarchive: number; create: number; createNames: string[] } } { + const calls = { list: 0, unarchive: 0, create: 0, createNames: [] as string[] }; + const fake: FakeClient = { + conversations: { + list: async (args) => { + calls.list++; + return opts.list(args); + }, + unarchive: async (args) => { + calls.unarchive++; + if (!opts.unarchive) throw new Error('unarchive not stubbed'); + return opts.unarchive(args); + }, + create: async (args) => { + calls.create++; + calls.createNames.push(args.name); + if (!opts.create) throw new Error('create not stubbed'); + return opts.create(args); + }, + }, + }; + return { client: fake as unknown as WebClient, calls }; +} + +const AGENT = { id: 'abcd1234efgh', name: 'My Agent' }; +const EXPECTED_NAME = 'maestro-my-agent-abcd1234'; + +test('returns existing channel when found and not archived', async () => { + const { client, calls } = makeClient({ + list: async () => ({ + channels: [{ id: 'C-EXISTING', name: EXPECTED_NAME, is_archived: false }], + response_metadata: { next_cursor: '' }, + }), + }); + + const result = await findOrCreateSlackChannel(client, AGENT); + + assert.deepEqual(result, { channelId: 'C-EXISTING', isNew: false }); + assert.equal(calls.create, 0, 'must not create when an open channel exists'); + assert.equal(calls.unarchive, 0, 'must not unarchive an already-open channel'); +}); + +test('unarchives and returns existing channel when found archived', async () => { + const { client, calls } = makeClient({ + list: async () => ({ + channels: [{ id: 'C-ARCHIVED', name: EXPECTED_NAME, is_archived: true }], + response_metadata: { next_cursor: '' }, + }), + unarchive: async () => undefined, + }); + + const result = await findOrCreateSlackChannel(client, AGENT); + + assert.deepEqual(result, { channelId: 'C-ARCHIVED', isNew: false }); + assert.equal(calls.unarchive, 1, 'must unarchive the archived channel'); + assert.equal(calls.create, 0, 'must not create when unarchive succeeds'); +}); + +test('falls back to timestamped create when unarchive fails', async () => { + const { client, calls } = makeClient({ + list: async () => ({ + channels: [{ id: 'C-LOCKED', name: EXPECTED_NAME, is_archived: true }], + response_metadata: { next_cursor: '' }, + }), + unarchive: async () => { + throw new Error('channel_locked'); + }, + create: async () => ({ channel: { id: 'C-FRESH' } }), + }); + + const result = await findOrCreateSlackChannel(client, AGENT); + + assert.equal(result.channelId, 'C-FRESH'); + assert.equal(result.isNew, true); + assert.equal(calls.create, 1, 'must create a fallback channel when unarchive fails'); + assert.equal(calls.createNames.length, 1); + // The fallback name keeps the original base and appends -<6 digits>. + assert.match(calls.createNames[0], new RegExp(`^${EXPECTED_NAME}-\\d{6}$`)); +}); + +test('creates with primary name when channel does not exist', async () => { + const { client, calls } = makeClient({ + list: async () => ({ + channels: [{ id: 'C-OTHER', name: 'unrelated' }], + response_metadata: { next_cursor: '' }, + }), + create: async () => ({ channel: { id: 'C-NEW' } }), + }); + + const result = await findOrCreateSlackChannel(client, AGENT); + + assert.deepEqual(result, { channelId: 'C-NEW', isNew: true }); + assert.equal(calls.create, 1); + assert.deepEqual(calls.createNames, [EXPECTED_NAME], 'must create with the primary (un-suffixed) name'); +}); + +test('proceeds to create when conversations.list throws', async () => { + // Network/auth error during list shouldn't block channel provisioning; + // the adapter swallows the list error and falls through to create. + const { client, calls } = makeClient({ + list: async () => { + throw new Error('rate_limited'); + }, + create: async () => ({ channel: { id: 'C-NEW' } }), + }); + + const result = await findOrCreateSlackChannel(client, AGENT); + + assert.deepEqual(result, { channelId: 'C-NEW', isNew: true }); + assert.equal(calls.create, 1); +}); + +test('throws when conversations.create returns no channel id', async () => { + const { client } = makeClient({ + list: async () => ({ + channels: [], + response_metadata: { next_cursor: '' }, + }), + create: async () => ({ channel: undefined }), + }); + + await assert.rejects( + findOrCreateSlackChannel(client, AGENT), + /Failed to create Slack channel for agent/, + ); +}); + +test('walks pagination during channel lookup', async () => { + const pages: ListResult[] = [ + { + channels: [{ id: 'C1', name: 'general' }], + response_metadata: { next_cursor: 'cursor-1' }, + }, + { + channels: [{ id: 'C2', name: 'random' }], + response_metadata: { next_cursor: 'cursor-2' }, + }, + { + channels: [{ id: 'C-MATCH', name: EXPECTED_NAME, is_archived: false }], + response_metadata: { next_cursor: '' }, + }, + ]; + const { client, calls } = makeClient({ + list: async (args) => { + if (!args.cursor) return pages[0]; + if (args.cursor === 'cursor-1') return pages[1]; + if (args.cursor === 'cursor-2') return pages[2]; + throw new Error(`unexpected cursor: ${args.cursor}`); + }, + }); + + const result = await findOrCreateSlackChannel(client, AGENT); + + assert.deepEqual(result, { channelId: 'C-MATCH', isNew: false }); + assert.equal(calls.list, 3, 'must walk all three pages'); + assert.equal(calls.create, 0); +}); diff --git a/src/providers/slack/adapter.ts b/src/providers/slack/adapter.ts index 28ac202..7230004 100644 --- a/src/providers/slack/adapter.ts +++ b/src/providers/slack/adapter.ts @@ -324,7 +324,14 @@ export class SlackProvider implements BridgeProvider { if (isThreadTs(target.channelId)) { // target is a thread_ts β€” look up parent channel const convo = conversationDb.get(target.channelId); - if (!convo) throw new Error(`No conversation found for thread_ts ${target.channelId}`); + if (!convo) { + // The thread is orphaned β€” its row was likely removed when the + // bound channel was disconnected, or the DB was reset. Log the + // mismatch specifically so operators can distinguish it from + // generic Slack/network errors before surfacing to the kernel. + void logger.error('slack/send:orphan-thread', `thread_ts=${target.channelId}`); + throw new Error(`No conversation found for thread_ts ${target.channelId}`); + } await this.client.chat.postMessage({ channel: convo.channel_id, thread_ts: target.channelId, @@ -344,7 +351,10 @@ export class SlackProvider implements BridgeProvider { if (isThreadTs(target.channelId)) { const convo = conversationDb.get(target.channelId); - if (!convo) throw new Error(`No conversation found for thread_ts ${target.channelId}`); + if (!convo) { + void logger.error('slack/react:orphan-thread', `thread_ts=${target.channelId}`); + throw new Error(`No conversation found for thread_ts ${target.channelId}`); + } channel = convo.channel_id; timestamp = target.messageId; } else { diff --git a/src/providers/slack/commands/agents.ts b/src/providers/slack/commands/agents.ts index bec2997..df400aa 100644 --- a/src/providers/slack/commands/agents.ts +++ b/src/providers/slack/commands/agents.ts @@ -95,11 +95,13 @@ async function handleNew( return; } + // Lookup mirrors Discord's /agents new: exact id, id-prefix, or exact name. + // Keeping the two providers identical here means agent IDs/names that work + // in one chat platform work in the other. const agents = await maestro.listAgents(); - let agent = agents.find((a) => a.id === agentId); - if (!agent) { - agent = agents.find((a) => a.name.toLowerCase() === agentId.toLowerCase()); - } + const agent = agents.find( + (a) => a.id === agentId || a.id.startsWith(agentId) || a.name === agentId, + ); if (!agent) { await say(`Agent \`${agentId}\` not found. Use \`/agents list\` to see available agents.`); return; From 0850c6ea63e2cc42bf51c70416e64c66b6a1347f Mon Sep 17 00:00:00 2001 From: chr1syy Date: Mon, 11 May 2026 10:05:47 +0200 Subject: [PATCH 31/31] chore: bump version to 0.2.0 for Slack provider release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack is a new built-in provider β€” a feature, not a bugfix β€” so this bumps to 0.2.0 per semver. Patch releases since 0.1.0 (0.1.1 through 0.1.3) were installer/workflow fixes; this is the first release with a new chat platform onboarded. --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6c17060..5692dbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "maestro-relay", - "version": "0.1.3", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "maestro-relay", - "version": "0.1.3", + "version": "0.2.0", "license": "MIT", "dependencies": { "@slack/bolt": "^4.6.0", diff --git a/package.json b/package.json index 958f831..2c42a7e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "maestro-relay", - "version": "0.1.3", + "version": "0.2.0", "description": "Maestro Relay β€” connect chat platforms (Discord today, Slack/Teams next) to Maestro AI agents via maestro-cli.", "main": "dist/index.js", "bin": {