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,
|
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.
|
||||||
|
|||||||
@@ -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
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 { 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);
|
||||||
|
|||||||
Reference in New Issue
Block a user