From 2671fe0f34a8ad0e807eab07fc64f185cf00239b Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Tue, 8 Sep 2026 08:33:34 +0200 Subject: [PATCH] feat: add Discord help command for servers and DMs --- src/discord/README.md | 5 +++++ src/discord/commands.ts | 1 + src/discord/help.test.ts | 33 +++++++++++++++++++++++++++++++++ src/discord/help.ts | 22 ++++++++++++++++++++++ src/discord/routes.ts | 7 +++++-- 5 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 src/discord/help.test.ts create mode 100644 src/discord/help.ts diff --git a/src/discord/README.md b/src/discord/README.md index a59823b..b8638bb 100644 --- a/src/discord/README.md +++ b/src/discord/README.md @@ -19,3 +19,8 @@ Tests use real Ed25519 signatures and disposable migrated SQLite databases. The foundation tests cover server/DM privacy, replay after restart, stale progress, cross-account controls, and matching website state. Command registration and live Discord validation will be completed during integration. + +`/help` lists the current command definitions automatically, explains check-in +controls and privacy, and links to the website. It works before account setup and +does not create an account or synchronize habits. Re-run the registration script +with `--apply` after deployment to expose newly added commands in Discord. diff --git a/src/discord/commands.ts b/src/discord/commands.ts index e198f31..ea41fb1 100644 --- a/src/discord/commands.ts +++ b/src/discord/commands.ts @@ -1,4 +1,5 @@ // Global commands are required for bot DMs; guild-only registrations cannot run there. export const commands = [ + { name: 'help', description: 'Show commands and how to use Minabot' }, { name: 'today', description: 'View and complete today’s habits' }, ].map(command => ({ ...command, type: 1, integration_types: [0], contexts: [0, 1] })); diff --git a/src/discord/help.test.ts b/src/discord/help.test.ts new file mode 100644 index 0000000..4b4e3e6 --- /dev/null +++ b/src/discord/help.test.ts @@ -0,0 +1,33 @@ +import { afterEach, expect, test } from 'bun:test'; +import { discordFixture } from './test-fixture'; +import { commands } from './commands'; +import { help } from './help'; +import { discordControls, discordReceipts, users } from '../db/schema'; +let f: ReturnType; +afterEach(() => f?.close()); + +for (const guild of [false, true]) test(`/help works in ${guild ? 'servers privately' : 'bot DMs'} without creating an account`, async () => { + f = discordFixture(); + const before = f.db.select().from(users).all(); + const response = await f.run({ data: { name: 'help' } }, guild, '555555555555555555'); + expect(response.type).toBe(4); + expect(response.data.flags).toBe(guild ? 64 : undefined); + expect(response.data.allowed_mentions).toEqual({ parse: [] }); + for (const command of commands) expect(response.data.content).toContain(`**/${command.name}** — ${command.description}`); + expect(response.data.components[0].components[0].url).toBe(`${f.origin}/`); + expect(f.db.select().from(users).all()).toEqual(before); + expect(f.db.select().from(discordControls).all()).toEqual([]); + expect(f.db.select().from(discordReceipts).all()).toEqual([]); + expect(f.changed).toEqual([]); +}); + +test('help stays within Discord message limits as command definitions grow', () => { + const response = help(true, 'https://example.com'); + expect('content' in response.data! && response.data.content!.length).toBeLessThanOrEqual(2000); + expect(commands.find(c => c.name === 'help')?.contexts).toEqual([0, 1]); +}); + +test('unknown commands point users to help', async () => { + f = discordFixture(); + expect((await f.run({ data: { name: 'unknown' } }, true)).data.content).toContain('/help'); +}); diff --git a/src/discord/help.ts b/src/discord/help.ts new file mode 100644 index 0000000..03fe553 --- /dev/null +++ b/src/discord/help.ts @@ -0,0 +1,22 @@ +import { commands } from './commands'; +import type { Reply } from './types'; + +/** Use registration metadata so newly integrated commands appear automatically. */ +export function help(guild: boolean, origin: string): Reply { + return { type: 4, data: { + content: [ + '**Minabot help**', + 'Manage the same habits here and on the website. Commands work in servers and in DMs with the bot.', + '', + ...commands.map(command => `**/${command.name}** — ${command.description}`), + '', + 'Start with /today, then choose a habit and use its buttons to record progress. Use the forms for amounts and other details.', + 'If a control expires or progress changes elsewhere, run the command again to get fresh controls.', + '', + 'Personal command replies in servers are visible only to you. Sharing progress requires an explicit sharing action.', + ].join('\n'), + ...(guild ? { flags: 64 } : {}), + allowed_mentions: { parse: [] }, + components: [{ type: 1, components: [{ type: 2, style: 5, label: 'Open website', url: `${origin}/` }] }], + } }; +} diff --git a/src/discord/routes.ts b/src/discord/routes.ts index 89b077c..c3ef251 100644 --- a/src/discord/routes.ts +++ b/src/discord/routes.ts @@ -10,6 +10,7 @@ import { ControlStore } from './state'; import { interactionSchema, type Context, type Reply } from './types'; import { dailyAction, today } from './daily'; import { message } from './ui'; +import { help } from './help'; export type CommandConfig = { publicKey: string; applicationId: string; origin: string }; export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now = Date.now, changed: (userId: string) => void = () => {}) { @@ -31,6 +32,8 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now if (i.type === 1) return c.json({ type: 1 }); const profile = i.guild_id ? i.member?.user : i.user; if (!profile || ![2, 3, 4, 5].includes(i.type)) return c.json({ error: 'Unsupported interaction.' }, 400); + // Help needs no account creation, habit synchronization, or saved UI state. + if (i.type === 2 && i.data?.name === 'help') return c.json(help(!!i.guild_id, config.origin)); const reply = db.transaction((): Reply => { const existing = db.select().from(users).where(eq(users.discordId, profile.id)).get(); const userId = existing?.id ?? crypto.randomUUID(); @@ -47,8 +50,8 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now else if (i.type === 2 && i.data?.name === 'today') result = today(ctx); else if ([3, 5].includes(i.type)) { const state = controls.read(i.data?.custom_id ?? ''); - result = dailyAction(ctx, state.action, state.payload) ?? message(ctx, 'Run /today to get started.', [], false); - } else result = message(ctx, 'Run /today to get started.', [], false); + result = dailyAction(ctx, state.action, state.payload) ?? message(ctx, 'Run /help to see available commands.', [], false); + } else result = message(ctx, 'Run /help to see available commands.', [], false); } catch (error) { if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error; result = message(ctx, error instanceof ApiError ? error.message : 'Check the entered values and try again.', [], false);