feat: manage habits through private Discord controls
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
// 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: 'help', description: 'Show commands and how to use Minabot' },
|
||||||
|
{ name: 'habits', description: 'Create, edit, archive and restore your habits' },
|
||||||
{ 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] }));
|
||||||
|
|||||||
98
src/discord/management.test.ts
Normal file
98
src/discord/management.test.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { afterEach, expect, test } from 'bun:test';
|
||||||
|
import { discordFixture, control, submit } from './test-fixture';
|
||||||
|
let f: ReturnType<typeof discordFixture>;
|
||||||
|
afterEach(() => f?.close());
|
||||||
|
async function click(reply: any, label: string, guild = false, values?: string[]) {
|
||||||
|
return f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(reply, label), ...(values ? { values } : {}) } }, guild);
|
||||||
|
}
|
||||||
|
async function open(id: string, guild = false) { return click(await f.run({ data: { name: 'habits' } }, guild), 'Choose a habit to manage', guild, [id]); }
|
||||||
|
async function create(method: string, guild = false) {
|
||||||
|
const list = await f.run({ data: { name: 'habits' } }, guild);
|
||||||
|
expect(list.data.flags).toBe(guild ? 64 : undefined);
|
||||||
|
const methodMenu = await click(list, 'Create habit', guild);
|
||||||
|
const form = await click(methodMenu, 'Tracking method', guild, [method]);
|
||||||
|
const result = await f.run(submit(form.data.custom_id, { name: 'Read', color: '#123456', ...(method === 'count' ? { target: '10', unit: 'pages' } : {}) }), guild);
|
||||||
|
expect(result.data.content).toContain('Read');
|
||||||
|
return { result, habit: (await f.json('/habits')).habits[0] };
|
||||||
|
}
|
||||||
|
for (const guild of [false, true]) test(`habit lifecycle and shared website state in ${guild ? 'server' : 'DM'}`, async () => {
|
||||||
|
f = discordFixture(); const { result, habit } = await create('count', guild);
|
||||||
|
expect(habit.method).toBe('count'); expect(habit.target).toBe(10);
|
||||||
|
let detail = await click(result, 'Enable carryover', guild);
|
||||||
|
expect((await f.json('/habits')).habits[0].carryPartialProgress).toBe(true);
|
||||||
|
const edit = await click(detail, 'Edit details', guild);
|
||||||
|
detail = await f.run(submit(edit.data.custom_id, { name: 'Books', target: '20', unit: 'pages', color: '#654321' }), guild);
|
||||||
|
expect((await f.json('/habits')).habits[0].name).toBe('Books');
|
||||||
|
const confirmation = await click(detail, 'Archive habit', guild);
|
||||||
|
detail = await click(confirmation, 'Confirm', guild);
|
||||||
|
expect((await f.json('/habits')).habits).toEqual([]);
|
||||||
|
detail = await click(await click(detail, 'Restore habit', guild), 'Confirm', guild);
|
||||||
|
expect((await f.json('/habits')).habits[0].id).toBe(habit.id);
|
||||||
|
detail = await click(await click(detail, 'Archive habit', guild), 'Confirm', guild);
|
||||||
|
const archived = await click(await f.run({ data: { name: 'habits' } }, guild), 'Browse archived', guild);
|
||||||
|
expect(control(archived, 'Choose a habit to manage')).toBeTruthy();
|
||||||
|
detail = await click(await click(detail, 'Permanently delete', guild), 'Confirm', guild);
|
||||||
|
expect(detail.data.content).toContain('No habits');
|
||||||
|
expect((await f.request(`/habits/${habit.id}/days/2026-09-04`)).status).toBe(404);
|
||||||
|
expect(f.changed.length).toBe(7);
|
||||||
|
});
|
||||||
|
test('all recurrences use validated friendly controls', async () => {
|
||||||
|
f = discordFixture(); const { habit } = await create('manual');
|
||||||
|
for (const [kind, values, expected] of [
|
||||||
|
['interval', { every: '3', anchor: '2026-09-01' }, { type: 'interval', every: 3, anchor: '2026-09-01' }],
|
||||||
|
['weekly', { every: '2', anchor: '2026-09-01', weekday: 'Friday' }, { type: 'weekly', every: 2, anchor: '2026-09-01', weekday: 5 }],
|
||||||
|
] as const) {
|
||||||
|
const menu = await click(await open(habit.id), 'Change schedule');
|
||||||
|
const form = await click(menu, 'Recurrence', false, [kind]);
|
||||||
|
await f.run(submit(form.data.custom_id, { ...values }));
|
||||||
|
expect((await f.json('/habits')).habits[0].schedule).toEqual(expected);
|
||||||
|
}
|
||||||
|
let menu = await click(await click(await open(habit.id), 'Change schedule'), 'Recurrence', false, ['weekdays']);
|
||||||
|
await click(menu, 'Select weekdays', false, ['1', '3', '5']);
|
||||||
|
expect((await f.json('/habits')).habits[0].schedule).toEqual({ type: 'weekdays', days: [1, 3, 5] });
|
||||||
|
menu = await click(await open(habit.id), 'Change schedule');
|
||||||
|
await click(menu, 'Recurrence', false, ['daily']);
|
||||||
|
expect((await f.json('/habits')).habits[0].schedule).toEqual({ type: 'daily' });
|
||||||
|
const invalid = await click(await click(await open(habit.id), 'Change schedule'), 'Recurrence', false, ['interval']);
|
||||||
|
expect((await f.run(submit(invalid.data.custom_id, { every: '0', anchor: '2026-02-30' }))).data.content).toContain('Check the entered');
|
||||||
|
expect((await f.json('/habits')).habits[0].schedule).toEqual({ type: 'daily' });
|
||||||
|
});
|
||||||
|
test('method conversion warns, resets current progress and preserves past dated requirements', async () => {
|
||||||
|
f = discordFixture(); const { habit } = await create('manual');
|
||||||
|
await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { done: true });
|
||||||
|
f.setDiscordTime('2026-09-05T12:00:00Z');
|
||||||
|
const menu = await click(await open(habit.id), 'Change method');
|
||||||
|
const confirmation = await click(menu, 'New tracking method', false, ['count']);
|
||||||
|
expect(confirmation.data.content).toContain('resets today');
|
||||||
|
const form = await click(confirmation, 'Continue conversion');
|
||||||
|
await f.run(submit(form.data.custom_id, { name: 'Read', color: '#123456', target: '5', unit: 'pages' }));
|
||||||
|
const past = await f.json(`/habits/${habit.id}/days/2026-09-04`);
|
||||||
|
expect(past.method).toBe('manual'); expect(past.value).toBe(1);
|
||||||
|
const today = await f.json(`/habits/${habit.id}/days/2026-09-05`);
|
||||||
|
expect(today.method).toBe('count'); expect(today.value).toBe(0);
|
||||||
|
const next = await click(await click(await open(habit.id), 'Change method'), 'New tracking method', false, ['tasks']);
|
||||||
|
await click(next, 'Continue conversion');
|
||||||
|
expect((await f.json('/habits')).habits[0].tasks).toEqual([]);
|
||||||
|
});
|
||||||
|
test('stale, replayed, and cross-account management controls cannot write', async () => {
|
||||||
|
f = discordFixture(); const { result, habit } = await create('manual');
|
||||||
|
const form = await click(result, 'Edit details');
|
||||||
|
await f.json(`/habits/${habit.id}`, 'PATCH', { name: 'Website edit' });
|
||||||
|
expect((await f.run(submit(form.data.custom_id, { name: 'Stale', color: '#123456' }))).data.content).toContain('habit changed');
|
||||||
|
const fresh = await click(await open(habit.id), 'Edit details');
|
||||||
|
const payload = f.payload(submit(fresh.data.custom_id, { name: 'Final', color: '#123456' }));
|
||||||
|
await f.requestDiscord(payload); const changes = f.changed.length;
|
||||||
|
f.restart(); await f.requestDiscord(payload);
|
||||||
|
expect(f.changed.length).toBe(changes);
|
||||||
|
expect((await f.run(submit(fresh.data.custom_id, { name: 'Replay', color: '#123456' }))).data.content).toContain('expired');
|
||||||
|
expect((await f.run(submit(fresh.data.custom_id, { name: 'Attack', color: '#123456' }), true, f.bob)).data.content).toContain('belongs to another');
|
||||||
|
expect((await f.json('/habits')).habits[0].name).toBe('Final');
|
||||||
|
});
|
||||||
|
test('pagination keeps Discord component and option limits', async () => {
|
||||||
|
f = discordFixture(); for (let i = 0; i < 12; i++) await f.json('/habits', 'POST', { name: `Habit ${i}`, method: 'manual' }, 201);
|
||||||
|
const page = await f.run({ data: { name: 'habits' } });
|
||||||
|
expect(page.data.components[0].components[0].options.length).toBe(10);
|
||||||
|
const next = await click(page, 'Next');
|
||||||
|
expect(next.data.components[0].components[0].options.length).toBe(2);
|
||||||
|
expect(control(next, 'Previous')).toBeTruthy();
|
||||||
|
});
|
||||||
122
src/discord/management.ts
Normal file
122
src/discord/management.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { habitInput, habitPatch, scheduleSchema, type Schedule } from '../habits/contracts';
|
||||||
|
import { ApiError } from '../habits/service';
|
||||||
|
import { fields, type Context, type ControlData, type Reply } from './types';
|
||||||
|
import { button, message, modal, pages, row, select, text } from './ui';
|
||||||
|
|
||||||
|
const prefix = 'manage:';
|
||||||
|
const methods = [{ id: 'manual', name: 'Manual check-in' }, { id: 'count', name: 'Count toward a target' }, { id: 'tasks', name: 'Checklist of tasks' }];
|
||||||
|
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||||
|
const recurrence = [{ id: 'daily', name: 'Every day' }, { id: 'weekdays', name: 'Selected weekdays' }, { id: 'interval', name: 'Every N days' }, { id: 'weekly', name: 'Every N weeks' }];
|
||||||
|
function stamp(c: Context, id: string) {
|
||||||
|
const h = c.service.current(id), day = c.service.day(id, c.service.today);
|
||||||
|
return JSON.stringify([h.revisionId, c.service.settings(id), c.service.today, c.service.user.timezone, day.value, day.tasks.map(t => [t.taskId, t.done])]);
|
||||||
|
}
|
||||||
|
function snapshot(c: Context, id: string): ControlData { return { habitId: id, expected: stamp(c, id) }; }
|
||||||
|
function check(c: Context, d: ControlData) {
|
||||||
|
if (!d.habitId || d.expected !== stamp(c, d.habitId)) throw new ApiError(409, 'This habit changed. Run /habits or /tasks again before saving.');
|
||||||
|
return c.service.current(d.habitId);
|
||||||
|
}
|
||||||
|
function consume(c: Context) { c.controls.consume(c.interaction.data!.custom_id!); c.changed(); }
|
||||||
|
function chosen(c: Context, allowed: string[]) {
|
||||||
|
const value = c.interaction.data?.values?.[0];
|
||||||
|
if (!value || !allowed.includes(value)) throw new ApiError(422, 'Choose one of the available options.');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
export function scheduleLabel(s: Schedule): string {
|
||||||
|
if (s.type === 'daily') return 'Every day';
|
||||||
|
if (s.type === 'weekdays') return s.days.map(d => weekdays[d]).join(', ');
|
||||||
|
if (s.type === 'interval') return `Every ${s.every} days from ${s.anchor}`;
|
||||||
|
return `Every ${s.every} weeks on ${weekdays[s.weekday]} from ${s.anchor}`;
|
||||||
|
}
|
||||||
|
export function manageHabits(c: Context, d: ControlData = {}): Reply {
|
||||||
|
const archived = d.mode === 'archived';
|
||||||
|
const habits = c.service.list(true).filter(h => h.archived === archived);
|
||||||
|
const page = Math.max(0, Math.min(d.page ?? 0, Math.max(0, Math.ceil(habits.length / 10) - 1)));
|
||||||
|
const state = { mode: archived ? 'archived' : 'active', page };
|
||||||
|
return message(c, `**${archived ? 'Archived' : 'Active'} habits** · Page ${page + 1}\n${habits.length ? 'Choose a habit to manage.' : 'No habits here yet.'}`, [
|
||||||
|
...(habits.length ? [select(c, 'Choose a habit to manage', prefix + 'open', state, habits.slice(page * 10, page * 10 + 10).map(h => ({ id: h.id, name: h.name, description: scheduleLabel(h.schedule) })))] : []),
|
||||||
|
row(button(c, 'Create habit', prefix + 'create'), button(c, archived ? 'Browse active' : 'Browse archived', prefix + 'list', { mode: archived ? 'active' : 'archived' })),
|
||||||
|
...pages(c, prefix + 'list', state, habits.length),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
function detail(c: Context, id: string): Reply {
|
||||||
|
const h = c.service.current(id), d = snapshot(c, id);
|
||||||
|
return message(c, `**${text(h.name)}**${h.archived ? ' · Archived' : ''}\n${h.method === 'count' ? `${h.target} ${text(h.unit)} · Carry partial progress: ${h.carryPartialProgress ? 'on' : 'off'}` : h.method === 'tasks' ? `${h.tasks.length} tasks` : 'Manual check-in'}\n${scheduleLabel(h.schedule)}\nColor: ${c.service.settings(id).mainColor}\nChanges apply from ${c.service.today} (${text(c.service.user.timezone)}); earlier requirements remain unchanged.`, [
|
||||||
|
row(button(c, 'Edit details', prefix + 'edit', d), button(c, 'Change schedule', prefix + 'schedule', d), button(c, 'Change method', prefix + 'method', d)),
|
||||||
|
...(h.method === 'count' ? [row(button(c, h.carryPartialProgress ? 'Disable carryover' : 'Enable carryover', prefix + 'carry', d))] : []),
|
||||||
|
row(button(c, h.archived ? 'Restore habit' : 'Archive habit', prefix + 'confirm', { ...d, mode: h.archived ? 'restore' : 'archive' }), ...(h.archived ? [button(c, 'Permanently delete', prefix + 'confirm', { ...d, mode: 'delete' }, 4)] : []), button(c, 'Back to habits', prefix + 'list')),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
function detailsForm(c: Context, d: ControlData, creating = false): Reply {
|
||||||
|
const h = creating ? undefined : check(c, d), method = d.method ?? h!.method;
|
||||||
|
return modal(c, creating ? 'Create habit' : 'Edit habit', prefix + (creating ? 'save-create' : 'save-details'), { ...d, method }, [
|
||||||
|
{ id: 'name', label: 'Habit name', value: h?.name },
|
||||||
|
...(method === 'count' ? [{ id: 'target', label: 'Target (1–10000)', value: h?.method === 'count' ? String(h.target) : '1' }, { id: 'unit', label: 'Unit (pages, steps, minutes…)', max: 80, value: h?.method === 'count' ? h.unit : 'times' }] : []),
|
||||||
|
{ id: 'color', label: 'Color (#RRGGBB)', value: h ? c.service.settings(h.id).mainColor : '#196127', max: 7 },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
function schedulePicker(c: Context, d: ControlData): Reply {
|
||||||
|
check(c, d);
|
||||||
|
return message(c, 'Choose a recurrence. Dates use your account timezone.', [select(c, 'Recurrence', prefix + 'schedule-kind', d, recurrence), row(button(c, 'Cancel', prefix + 'detail', d))]);
|
||||||
|
}
|
||||||
|
function saveSchedule(c: Context, d: ControlData, schedule: Schedule): Reply {
|
||||||
|
check(c, d); c.service.edit(d.habitId!, habitPatch.parse({ schedule })); consume(c); return detail(c, d.habitId!);
|
||||||
|
}
|
||||||
|
export function managementAction(c: Context, action: string, d: ControlData): Reply | undefined {
|
||||||
|
if (!action.startsWith(prefix)) return;
|
||||||
|
const a = action.slice(prefix.length);
|
||||||
|
if (a === 'list') return manageHabits(c, d);
|
||||||
|
if (a === 'open') return detail(c, chosen(c, c.service.list(true).filter(h => h.archived === (d.mode === 'archived')).map(h => h.id)));
|
||||||
|
if (a === 'detail') return detail(c, d.habitId!);
|
||||||
|
if (a === 'create') return message(c, 'Choose how to track your habit. New habits start with a daily schedule; you can change it after creating.', [select(c, 'Tracking method', prefix + 'create-method', {}, methods)]);
|
||||||
|
if (a === 'create-method') return detailsForm(c, { method: chosen(c, methods.map(m => m.id)) }, true);
|
||||||
|
if (a === 'save-create') {
|
||||||
|
const f = fields(c.interaction);
|
||||||
|
const h = c.service.create(habitInput.parse({ name: f.name, color: f.color, method: d.method, ...(d.method === 'count' ? { target: Number(f.target), unit: f.unit } : {}) }));
|
||||||
|
consume(c); return detail(c, h.id);
|
||||||
|
}
|
||||||
|
if (a === 'edit') return detailsForm(c, d);
|
||||||
|
if (a === 'save-details') {
|
||||||
|
check(c, d); const f = fields(c.interaction);
|
||||||
|
c.service.edit(d.habitId!, habitPatch.parse({ name: f.name, color: f.color, method: d.method, ...(d.method === 'count' ? { target: Number(f.target), unit: f.unit } : {}) }));
|
||||||
|
consume(c); return detail(c, d.habitId!);
|
||||||
|
}
|
||||||
|
if (a === 'carry') { const h = check(c, d); if (h.method !== 'count') throw new ApiError(409, 'This habit no longer uses counts.'); c.service.edit(h.id, { carryPartialProgress: !h.carryPartialProgress }); consume(c); return detail(c, h.id); }
|
||||||
|
if (a === 'method') { const h = check(c, d); return message(c, 'Choose a new tracking method.', [select(c, 'New tracking method', prefix + 'method-confirm', d, methods.filter(m => m.id !== h.method))]); }
|
||||||
|
if (a === 'method-confirm') {
|
||||||
|
const h = check(c, d), method = chosen(c, methods.filter(m => m.id !== h.method).map(m => m.id));
|
||||||
|
return message(c, 'Changing method resets today’s progress. Switching away from tasks removes the current checklist; switching to tasks starts an empty checklist. Earlier dated history stays available. Count carryover starts off when switching to count. Continue?', [row(button(c, 'Continue conversion', prefix + 'convert', { ...d, method }, 4), button(c, 'Cancel', prefix + 'detail', d))]);
|
||||||
|
}
|
||||||
|
if (a === 'convert') {
|
||||||
|
check(c, d);
|
||||||
|
if (d.method === 'count') return detailsForm(c, d);
|
||||||
|
c.service.edit(d.habitId!, habitPatch.parse({ method: d.method })); consume(c); return detail(c, d.habitId!);
|
||||||
|
}
|
||||||
|
if (a === 'confirm') {
|
||||||
|
const h = check(c, d);
|
||||||
|
return message(c, d.mode === 'delete' ? `Permanently delete ${text(h.name)} and all its history? This cannot be undone and removes it from combined charts.` : `${d.mode === 'restore' ? 'Restore' : 'Archive'} ${text(h.name)} from today? Earlier history is preserved.`, [row(button(c, 'Confirm', prefix + 'apply-status', d, d.mode === 'delete' ? 4 : 1), button(c, 'Cancel', prefix + 'detail', d))]);
|
||||||
|
}
|
||||||
|
if (a === 'apply-status') { check(c, d); if (d.mode === 'delete') c.service.permanentlyDelete(d.habitId!); else c.service.edit(d.habitId!, { archived: d.mode === 'archive' }); consume(c); return d.mode === 'delete' ? manageHabits(c, { mode: 'archived' }) : detail(c, d.habitId!); }
|
||||||
|
if (a === 'schedule') return schedulePicker(c, d);
|
||||||
|
if (a === 'schedule-kind') {
|
||||||
|
const h = check(c, d), kind = chosen(c, recurrence.map(r => r.id));
|
||||||
|
if (kind === 'daily') return saveSchedule(c, d, { type: 'daily' });
|
||||||
|
if (kind === 'weekdays') {
|
||||||
|
const menu = select(c, 'Select weekdays', prefix + 'save-weekdays', d, weekdays.map((name, i) => ({ id: String(i), name })));
|
||||||
|
Object.assign((menu.components as Record<string, unknown>[])[0]!, { min_values: 1, max_values: 7 });
|
||||||
|
return message(c, 'Select all days on which this repeats.', [menu]);
|
||||||
|
}
|
||||||
|
const s = h.schedule;
|
||||||
|
return modal(c, kind === 'weekly' ? 'Repeat every N weeks' : 'Repeat every N days', prefix + 'save-interval', { ...d, mode: kind }, [
|
||||||
|
{ id: 'every', label: kind === 'weekly' ? 'Number of weeks (1–520)' : 'Number of days (1–3650)', value: 'every' in s ? String(s.every) : '1' },
|
||||||
|
{ id: 'anchor', label: 'Anchor date (YYYY-MM-DD)', value: 'anchor' in s ? s.anchor : c.service.today, max: 10 },
|
||||||
|
...(kind === 'weekly' ? [{ id: 'weekday', label: 'Weekday (Monday, Tuesday…)', value: s.type === 'weekly' ? weekdays[s.weekday] : 'Monday' }] : []),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (a === 'save-weekdays') return saveSchedule(c, d, scheduleSchema.parse({ type: 'weekdays', days: (c.interaction.data?.values ?? []).map(Number) }));
|
||||||
|
if (a === 'save-interval') {
|
||||||
|
const f = fields(c.interaction), weekday = weekdays.findIndex(day => day.toLowerCase() === f.weekday?.trim().toLowerCase());
|
||||||
|
return saveSchedule(c, d, scheduleSchema.parse({ type: d.mode, every: Number(f.every), anchor: f.anchor, ...(d.mode === 'weekly' ? { weekday } : {}) }));
|
||||||
|
}
|
||||||
|
throw new ApiError(422, 'Unknown management action. Run /habits again.');
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { ApiError, HabitService } from '../habits/service';
|
|||||||
import { ControlStore } from './state';
|
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 { manageHabits, managementAction } from './management';
|
||||||
import { message } from './ui';
|
import { message } from './ui';
|
||||||
import { help } from './help';
|
import { help } from './help';
|
||||||
|
|
||||||
@@ -47,10 +48,11 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now
|
|||||||
let result: Reply;
|
let result: Reply;
|
||||||
try {
|
try {
|
||||||
if (i.type === 4) result = { type: 8, data: { choices: [] } };
|
if (i.type === 4) result = { type: 8, data: { choices: [] } };
|
||||||
|
else if (i.type === 2 && i.data?.name === 'habits') result = manageHabits(ctx);
|
||||||
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 /help to see available commands.', [], false);
|
result = dailyAction(ctx, state.action, state.payload) ?? managementAction(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);
|
} 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user