feat: add Discord help command for servers and DMs

This commit is contained in:
syntaxbullet
2026-09-08 08:33:34 +02:00
parent 0a34142b3e
commit 2671fe0f34
5 changed files with 66 additions and 2 deletions

View File

@@ -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, foundation tests cover server/DM privacy, replay after restart, stale progress,
cross-account controls, and matching website state. Command registration and live cross-account controls, and matching website state. Command registration and live
Discord validation will be completed during integration. 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.

View File

@@ -1,4 +1,5 @@
// Global commands are required for bot DMs; guild-only registrations cannot run there. // Global commands are required for bot DMs; guild-only registrations cannot run there.
export const commands = [ export const commands = [
{ name: 'help', description: 'Show commands and how to use Minabot' },
{ name: 'today', description: 'View and complete today’s habits' }, { name: 'today', description: 'View and complete today’s habits' },
].map(command => ({ ...command, type: 1, integration_types: [0], contexts: [0, 1] })); ].map(command => ({ ...command, type: 1, integration_types: [0], contexts: [0, 1] }));

33
src/discord/help.test.ts Normal file
View File

@@ -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<typeof discordFixture>;
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');
});

22
src/discord/help.ts Normal file
View File

@@ -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}/` }] }],
} };
}

View File

@@ -10,6 +10,7 @@ import { ControlStore } from './state';
import { interactionSchema, type Context, type Reply } from './types'; import { interactionSchema, type Context, type Reply } from './types';
import { dailyAction, today } from './daily'; import { dailyAction, today } from './daily';
import { message } from './ui'; import { message } from './ui';
import { help } from './help';
export type CommandConfig = { publicKey: string; applicationId: string; origin: string }; export type CommandConfig = { publicKey: string; applicationId: string; origin: string };
export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now = Date.now, changed: (userId: string) => void = () => {}) { 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 }); if (i.type === 1) return c.json({ type: 1 });
const profile = i.guild_id ? i.member?.user : i.user; 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); 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 reply = db.transaction((): Reply => {
const existing = db.select().from(users).where(eq(users.discordId, profile.id)).get(); const existing = db.select().from(users).where(eq(users.discordId, profile.id)).get();
const userId = existing?.id ?? crypto.randomUUID(); 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 (i.type === 2 && i.data?.name === 'today') result = today(ctx);
else if ([3, 5].includes(i.type)) { else if ([3, 5].includes(i.type)) {
const state = controls.read(i.data?.custom_id ?? ''); const state = controls.read(i.data?.custom_id ?? '');
result = dailyAction(ctx, state.action, state.payload) ?? 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 /today to get started.', [], false); } else result = message(ctx, 'Run /help to see available commands.', [], false);
} catch (error) { } catch (error) {
if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw 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); result = message(ctx, error instanceof ApiError ? error.message : 'Check the entered values and try again.', [], false);