diff --git a/scripts/register-discord-commands.ts b/scripts/register-discord-commands.ts new file mode 100644 index 0000000..fcdec33 --- /dev/null +++ b/scripts/register-discord-commands.ts @@ -0,0 +1,10 @@ +import { commands } from '../src/discord/commands'; +import { registerCommands } from '../src/discord/registration'; + +if (process.argv.includes('--apply')) { + const registered = await registerCommands(process.env.DISCORD_CLIENT_ID ?? '', process.env.DISCORD_BOT_TOKEN ?? '', commands); + console.log(`Registered global commands for servers and bot DMs: ${registered.map(name => `/${name}`).join(', ')}`); +} else { + console.log(JSON.stringify(commands, null, 2)); + console.log('Preview only. Add --apply to register these commands with Discord.'); +} diff --git a/src/discord/registration.test.ts b/src/discord/registration.test.ts new file mode 100644 index 0000000..c1900b3 --- /dev/null +++ b/src/discord/registration.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from 'bun:test'; +import { registerCommands } from './registration'; +const command = { name: 'today', description: 'Today', type: 1, contexts: [0, 1], integration_types: [0] }; +test('registers global commands individually without removing other application commands', async () => { + const calls: { url: string; init?: RequestInit }[] = []; + expect(await registerCommands('111111111111111111', 'fake-token', [command], async (url, init) => { calls.push({ url, init }); return Response.json({}); })).toEqual(['today']); + expect(calls[0]!.url).toBe('https://discord.com/api/v10/applications/111111111111111111/commands'); + expect(calls[0]!.init!.method).toBe('POST'); + expect(JSON.parse(calls[0]!.init!.body as string).contexts).toEqual([0, 1]); +}); +test('rejects missing DM support before sending any request', async () => { + let called = false; + await expect(registerCommands('111111111111111111', 'fake-token', [{ ...command, contexts: [0] }], async () => { called = true; return Response.json({}); })).rejects.toThrow('bot DMs'); + expect(called).toBe(false); +}); +test('stops on rate limits and does not expose response bodies', async () => { + await expect(registerCommands('111111111111111111', 'fake-token', [command], async () => new Response('private provider data', { status: 429, headers: { 'Retry-After': '5' } }))).rejects.toThrow('HTTP 429'); +}); diff --git a/src/discord/registration.ts b/src/discord/registration.ts new file mode 100644 index 0000000..cbeed09 --- /dev/null +++ b/src/discord/registration.ts @@ -0,0 +1,26 @@ +export type CommandDefinition = { name: string; description: string; type?: number; contexts: number[]; integration_types: number[]; options?: unknown[] }; +type Transport = (url: string, init?: RequestInit) => Promise; +/** Upsert only Minabot's commands. Never bulk-overwrite unrelated application commands. */ +export async function registerCommands(applicationId: string, token: string, definitions: CommandDefinition[], request: Transport = fetch) { + if (!/^\d{17,20}$/.test(applicationId) || !token.trim()) throw new Error('Set DISCORD_CLIENT_ID and DISCORD_BOT_TOKEN.'); + const names = new Set(); + for (const command of definitions) { + if (names.has(command.name)) throw new Error(`Duplicate command: ${command.name}`); + names.add(command.name); + if (!command.contexts.includes(0) || !command.contexts.includes(1)) throw new Error(`${command.name} must support servers and bot DMs.`); + } + const registered: string[] = []; + for (const command of definitions) { + const response = await request(`https://discord.com/api/v10/applications/${applicationId}/commands`, { + method: 'POST', headers: { Authorization: `Bot ${token.trim().replace(/^Bot\s+/i, '')}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(command), redirect: 'error', signal: AbortSignal.timeout(15000), + }); + if (!response.ok) { + // Provider payloads may contain sensitive data; report status only. + const retry = response.status === 429 ? ` Retry after ${response.headers.get('Retry-After') ?? 'a few'} seconds.` : ''; + throw new Error(`Registration of /${command.name} failed (HTTP ${response.status}).${retry} Already registered: ${registered.join(', ') || 'none'}. Rerunning safely upserts commands.`); + } + registered.push(command.name); + } + return registered; +}