feat: add Discord help command for servers and DMs
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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] }));
|
||||
|
||||
33
src/discord/help.test.ts
Normal file
33
src/discord/help.test.ts
Normal 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
22
src/discord/help.ts
Normal 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}/` }] }],
|
||||
} };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user