feat: polish Discord daily check-ins and add reminder buttons
This commit is contained in:
@@ -24,3 +24,12 @@ Discord validation will be completed during integration.
|
||||
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.
|
||||
Reminder delivery accepts `commandsAvailable`; production enables it when
|
||||
`DISCORD_PUBLIC_KEY` is a 64-digit hexadecimal key and `DISCORD_CLIENT_ID` is set.
|
||||
The existing bot token still controls delivery. Configured reminders include an
|
||||
owner-bound `today` control with `mode: 'reminder'`: it opens a separate private
|
||||
flow, leaving the reminder unchanged, and expires at the sending account's next
|
||||
local midnight (including DST). Delivery claims, nonce, opt-out, and retry rules
|
||||
are unchanged. The dashboard preview explicitly disables buttons because it has
|
||||
no signed interaction endpoint configured. Global command registration and the
|
||||
Discord interaction endpoint must be configured separately before production use.
|
||||
|
||||
112
src/discord/daily.test.ts
Normal file
112
src/discord/daily.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { afterEach, expect, test } from 'bun:test';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { discordFixture, control, submit } from './test-fixture';
|
||||
import { discordControls, reminderSettings } from '../db/schema';
|
||||
import { defaultReminder } from '../reminders/contracts';
|
||||
import { sendDueReminders } from '../reminders/worker';
|
||||
import { reminderCheckIn } from './daily';
|
||||
import { ControlStore } from './state';
|
||||
let f: ReturnType<typeof discordFixture>;
|
||||
afterEach(() => f?.close());
|
||||
const click = (id: string, values?: string[]) => ({ type: 3, message: { id: '123' }, data: { custom_id: id, ...(values ? { values } : {}) } });
|
||||
async function open(habitId: string, guild = false) {
|
||||
const t = await f.run({ data: { name: 'today' } }, guild);
|
||||
return f.run(click(control(t, 'Choose a habit'), [habitId]), guild);
|
||||
}
|
||||
for (const guild of [false, true]) {
|
||||
test(`daily count carry, above-target, reversal and recoverable modal saves (${guild ? 'server' : 'DM'})`, async () => {
|
||||
f = discordFixture();
|
||||
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'count', target: 10, carryPartialProgress: true }, 201);
|
||||
await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 7 });
|
||||
f.setDiscordTime('2026-09-05T12:00:00Z');
|
||||
let d = await open(h.id, guild); expect(d.data.content).toContain('Carried from 2026-09-04');
|
||||
d = await f.run(click(control(d, '+1')), guild);
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).value).toBe(8);
|
||||
let m = await f.run(click(control(d, 'Enter amount')), guild);
|
||||
const invalid = await f.run(submit(m.data.custom_id, { amount: '-1' }), guild);
|
||||
expect(invalid.data.content).toContain('whole number'); expect(invalid.data.flags).toBe(guild ? 64 : undefined);
|
||||
m = await f.run(click(control(invalid, 'Try again')), guild);
|
||||
d = await f.run(submit(m.data.custom_id, { amount: '12' }), guild);
|
||||
expect(d.data.content).toContain('12/10');
|
||||
m = await f.run(click(control(d, 'Enter amount')), guild);
|
||||
d = await f.run(submit(m.data.custom_id, { amount: '0' }), guild);
|
||||
expect(d.data.content).toContain('Incomplete');
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).value).toBe(0);
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(7);
|
||||
expect(f.changed).toHaveLength(3);
|
||||
});
|
||||
test(`manual and paginated task completion reverse and reflect website edits (${guild ? 'server' : 'DM'})`, async () => {
|
||||
f = discordFixture();
|
||||
const manual = await f.json('/habits', 'POST', { name: 'Walk', method: 'manual' }, 201);
|
||||
let d = await open(manual.id, guild);
|
||||
d = await f.run(click(control(d, 'Mark complete')), guild);
|
||||
expect((await f.json(`/habits/${manual.id}/days/2026-09-04`)).complete).toBe(true);
|
||||
d = await f.run(click(control(d, 'Mark incomplete')), guild);
|
||||
expect((await f.json(`/habits/${manual.id}/days/2026-09-04`)).complete).toBe(false);
|
||||
const h = await f.json('/habits', 'POST', { name: 'Routine', method: 'tasks', tasks: Array.from({ length: 11 }, (_, n) => ({ name: `Task ${n + 1}` })) }, 201);
|
||||
d = await open(h.id, guild);
|
||||
d = await f.run(click(control(d, 'Next')), guild);
|
||||
expect(d.data.content).toContain('Task 11'); expect(d.data.content).not.toContain('Task 10');
|
||||
const day = await f.json(`/habits/${h.id}/days/2026-09-04`);
|
||||
d = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild);
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(1);
|
||||
d = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild);
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0);
|
||||
// Website task changes invalidate an already-open Discord selection.
|
||||
for (const task of day.tasks) await f.json(`/habits/${h.id}/days/2026-09-04/tasks/${task.taskId}`, 'PUT', { done: true });
|
||||
const stale = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild);
|
||||
expect(stale.data.content).toContain('Progress changed');
|
||||
d = await f.run(click(control(stale, 'Refresh')), guild);
|
||||
expect(d.data.content).toContain('11/11 tasks · Complete');
|
||||
d = await f.run(click(control(d, 'Choose a task to change its completion'), [day.tasks[10].taskId]), guild);
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).complete).toBe(false);
|
||||
await f.json(`/habits/${manual.id}/days/2026-09-04/progress`, 'PUT', { done: true });
|
||||
expect((await open(manual.id, guild)).data.content).toContain('Complete');
|
||||
});
|
||||
}
|
||||
test('habit pagination clamps after website archives', async () => {
|
||||
f = discordFixture(); const habits = [];
|
||||
for (let n = 0; n < 11; n++) habits.push(await f.json('/habits', 'POST', { name: `Habit ${n}`, method: 'manual' }, 201));
|
||||
const t = await f.run({ data: { name: 'today' } });
|
||||
const next = control(t, 'Next');
|
||||
for (const h of habits.slice(1)) await f.json(`/habits/${h.id}`, 'PATCH', { archived: true });
|
||||
const refreshed = await f.run(click(next));
|
||||
expect(refreshed.data.content).toContain('Habit 0');
|
||||
});
|
||||
test('daily controls reject midnight rollover even within their fifteen minute lifetime', async () => {
|
||||
f = discordFixture(); f.setDiscordTime('2026-09-04T21:59:00Z');
|
||||
const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
|
||||
const d = await open(h.id);
|
||||
f.setDiscordTime('2026-09-04T22:00:00Z');
|
||||
const expired = await f.run(click(control(d, 'Mark complete')));
|
||||
expect(expired.data.content).toContain('day changed');
|
||||
const fresh = await f.run(click(control(expired, 'Today')));
|
||||
expect(fresh.data.content).toContain('2026-09-05');
|
||||
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0);
|
||||
});
|
||||
test('reminder buttons are owner bound, open a new flow, expire at local midnight, and preserve deduplication', async () => {
|
||||
f = discordFixture(); f.setDiscordTime('2026-09-04T18:00:00Z');
|
||||
await f.json('/habits', 'POST', { name: 'Walk', method: 'manual' }, 201);
|
||||
f.db.insert(reminderSettings).values({ userId: 'alice', ...defaultReminder, enabled: true, updatedAt: 0 }).run();
|
||||
const sent: any[] = []; const now = Date.parse('2026-09-04T18:00:00Z');
|
||||
const send = () => sendDueReminders(f.db, { token: 'fake', origin: f.origin, commandsAvailable: true }, async (url, init) => {
|
||||
if (url.endsWith('/users/@me/channels')) return Response.json({ id: '223456789012345678' });
|
||||
sent.push(JSON.parse(String(init?.body))); return Response.json({ id: '323456789012345678' });
|
||||
}, () => now);
|
||||
await Promise.all([send(), send()]); await send(); expect(sent).toHaveLength(1);
|
||||
const id = control({ data: sent[0] }, 'Check in');
|
||||
expect(f.db.select().from(discordControls).where(eq(discordControls.id, id.slice(5))).get()?.expiresAt).toBe(Date.parse('2026-09-04T22:00:00Z'));
|
||||
expect((await f.run(click(id), true, f.bob)).data.content).toContain('belongs to another user');
|
||||
for (const guild of [false, true]) {
|
||||
const flow = await f.run(click(id), guild); expect(flow.type).toBe(4); expect(flow.data.flags).toBe(guild ? 64 : undefined);
|
||||
expect(flow.data.content).toContain('Walk');
|
||||
}
|
||||
f.setDiscordTime('2026-09-04T22:00:00Z'); expect((await f.run(click(id))).data.content).toContain('expired');
|
||||
});
|
||||
test('reminder expiry follows DST boundary', () => {
|
||||
f = discordFixture();
|
||||
const now = Date.parse('2026-10-25T00:30:00Z');
|
||||
const components = reminderCheckIn(f.db, 'alice', 'Europe/Belgrade', now);
|
||||
const id = control({ data: { components } }, 'Check in');
|
||||
expect(new ControlStore(f.db, 'alice', now).read(id).expiresAt).toBe(Date.parse('2026-10-25T23:00:00Z'));
|
||||
});
|
||||
@@ -1,24 +1,35 @@
|
||||
import { ZodError } from 'zod';
|
||||
import { ApiError } from '../habits/service';
|
||||
import { endOfDay, localDate } from '../habits/calendar';
|
||||
import { ControlStore } from './state';
|
||||
import type { AppDatabase } from '../auth';
|
||||
import { progressInput } from '../habits/contracts';
|
||||
import type { Context, ControlData, Reply } from './types';
|
||||
import { fields } from './types';
|
||||
import { button, message, modal, pages, row, select, text } from './ui';
|
||||
const pageFor = (page: number | undefined, total: number) => Math.min(Math.max(0, Math.trunc(page ?? 0)), Math.max(0, Math.ceil(total / 10) - 1));
|
||||
const snapshot = (d: ReturnType<Context['service']['day']>) => JSON.stringify([d.value, d.revisionId, d.loggedCount, d.carriedFrom, d.tasks.map(t => [t.taskId, t.done])]);
|
||||
export function reminderCheckIn(db: AppDatabase, userId: string, timezone: string, now: number) {
|
||||
const date = localDate(now, timezone);
|
||||
return [row({ type: 2, style: 1, label: 'Check in', custom_id: new ControlStore(db, userId, now).create('today', { date, mode: 'reminder' }, endOfDay(date, timezone) - now) })];
|
||||
}
|
||||
export function today(c: Context, data: ControlData = {}): Reply {
|
||||
if (data.date && data.date !== c.service.today) throw new ApiError(409, 'That reminder is from an earlier day. Run /today for a fresh check-in.');
|
||||
const all = c.service.list().map(h => c.service.day(h.id, c.service.today)).filter(d => d.due);
|
||||
const page = data.page ?? 0, visible = all.slice(page * 10, page * 10 + 10);
|
||||
const page = pageFor(data.page, all.length), visible = all.slice(page * 10, page * 10 + 10);
|
||||
return message(c, `**Today · ${c.service.today}** · ${text(c.service.user.timezone)}\n${all.filter(h => h.complete).length}/${all.length} complete\n\n${visible.map(h => `${h.complete ? '✓' : '○'} ${text(h.name ?? 'Habit')} — ${h.value}/${h.target} ${text(h.unit ?? '')}`).join('\n') || 'No habits due. Use /habits to create or manage your habits.'}`,
|
||||
[...(visible.length ? [select(c, 'Choose a habit', 'daily.select', { date: c.service.today, today: true }, visible.map(h => ({ id: h.habitId, name: h.name ?? 'Habit' })))] : []), ...pages(c, 'today', data, all.length), row(button(c, 'Refresh', 'today'))], c.interaction.data?.custom_id?.startsWith('mina:') && data.mode !== 'reminder');
|
||||
[...(visible.length ? [select(c, 'Choose a habit', 'daily.select', { date: c.service.today, today: true }, visible.map(h => ({ id: h.habitId, name: h.name ?? 'Habit' })))] : []), ...pages(c, 'today', { date: c.service.today, page }, all.length), row(button(c, 'Refresh', 'today'))], c.interaction.data?.custom_id?.startsWith('mina:') && data.mode !== 'reminder');
|
||||
}
|
||||
export function detail(c: Context, data: ControlData): Reply {
|
||||
const date = data.date ?? c.service.today;
|
||||
if (data.today && date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today to check in.');
|
||||
const d = c.service.day(data.habitId!, date);
|
||||
const state: ControlData = { ...data, date, expected: JSON.stringify([d.value, d.revisionId, d.tasks.map(t => [t.taskId, t.done])]) };
|
||||
data = { ...data, date, page: pageFor(data.page, d.tasks.length) };
|
||||
const state: ControlData = { ...data, expected: snapshot(d) };
|
||||
const controls = [];
|
||||
if (d.due && !d.future) {
|
||||
if (d.method === 'manual') controls.push(row(button(c, d.complete ? 'Mark incomplete' : 'Mark complete', 'daily.save', { ...state, done: !d.complete }, 1)));
|
||||
if (d.method === 'count') controls.push(row(button(c, '+1', 'daily.save', { ...state, value: d.value + 1 }, 1), button(c, 'Enter amount', 'daily.amount', state)));
|
||||
if (d.method === 'count') controls.push(row(...(d.value < 1_000_000_000 ? [button(c, '+1', 'daily.save', { ...state, value: d.value + 1 }, 1)] : []), ...(d.value > 0 ? [button(c, '−1', 'daily.save', { ...state, value: d.value - 1 })] : []), button(c, 'Enter amount', 'daily.amount', state)));
|
||||
if (d.method === 'tasks') {
|
||||
const visible = d.tasks.slice((data.page ?? 0) * 10, ((data.page ?? 0) + 1) * 10);
|
||||
controls.push(select(c, 'Choose a task to change its completion', 'daily.task', state, visible.map(t => ({ id: t.taskId, name: `${t.done ? '✓ ' : '○ '}${t.name}` }))));
|
||||
@@ -26,17 +37,18 @@ export function detail(c: Context, data: ControlData): Reply {
|
||||
}
|
||||
}
|
||||
controls.push(row(button(c, 'Refresh', 'daily.detail', data), button(c, 'Today', 'today')));
|
||||
return message(c, `**${text(d.name ?? 'Not tracked')} · ${date}**\n${d.due ? `${d.value}/${d.target} ${text(d.unit ?? '')} · ${d.complete ? 'Complete' : 'Incomplete'}` : 'Not scheduled'}${d.future ? ' · Future dates are read-only' : ''}${d.method === 'tasks' ? '\n\n' + d.tasks.slice((data.page ?? 0) * 10, ((data.page ?? 0) + 1) * 10).map(t => `${t.done ? '✓' : '○'} ${text(t.name)}`).join('\n') : ''}`, controls);
|
||||
return message(c, `**${text(d.name ?? 'Not tracked')} · ${date}**\n${d.due ? `${d.value}/${d.target} ${text(d.unit ?? '')} · ${d.complete ? 'Complete' : 'Incomplete'}` : 'Not scheduled'}${d.carriedFrom ? ` · Carried from ${d.carriedFrom}` : ''}${d.future ? ' · Future dates are read-only' : ''}${d.method === 'tasks' ? '\n\n' + d.tasks.slice((data.page ?? 0) * 10, ((data.page ?? 0) + 1) * 10).map(t => `${t.done ? '✓' : '○'} ${text(t.name)}`).join('\n') : ''}`, controls);
|
||||
}
|
||||
export function dailyAction(c: Context, action: string, data: ControlData): Reply | undefined {
|
||||
function action(c: Context, action: string, data: ControlData): Reply | undefined {
|
||||
if (action === 'today') return today(c, data);
|
||||
if (action === 'daily.select') return detail(c, { ...data, habitId: c.interaction.data?.values?.[0] });
|
||||
if (action === 'daily.select') return detail(c, { ...data, page: 0, mode: undefined, habitId: c.interaction.data?.values?.[0] });
|
||||
if (action === 'daily.detail') return detail(c, data);
|
||||
if (action === 'daily.amount' && data.today && data.date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today again.');
|
||||
if (action === 'daily.amount') return modal(c, 'Record amount', 'daily.save', data, [{ id: 'amount', label: 'Total for this date', value: String(c.service.day(data.habitId!, data.date!).value), max: 10 }]);
|
||||
if (action === 'daily.save' || action === 'daily.task') {
|
||||
if (data.today && data.date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today again.');
|
||||
const d = c.service.day(data.habitId!, data.date!);
|
||||
if (data.expected !== JSON.stringify([d.value, d.revisionId, d.tasks.map(t => [t.taskId, t.done])])) throw new ApiError(409, 'Progress changed since this message was opened. Refresh it before saving.');
|
||||
if (data.expected !== snapshot(d)) throw new ApiError(409, 'Progress changed since this message was opened. Refresh it before saving.');
|
||||
if (action === 'daily.task') {
|
||||
const task = d.tasks.find(t => t.taskId === c.interaction.data?.values?.[0]);
|
||||
if (!task) throw new ApiError(404, 'Task not found.');
|
||||
@@ -48,3 +60,14 @@ export function dailyAction(c: Context, action: string, data: ControlData): Repl
|
||||
c.controls.consume(c.interaction.data!.custom_id!); c.changed(); return detail(c, data);
|
||||
}
|
||||
}
|
||||
|
||||
export function dailyAction(c: Context, name: string, data: ControlData): Reply | undefined {
|
||||
try { return action(c, name, data); }
|
||||
catch (error) {
|
||||
if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error;
|
||||
const invalid = error instanceof ZodError;
|
||||
const recover = invalid ? button(c, 'Try again', 'daily.amount', data) : button(c, 'Refresh', data.habitId ? 'daily.detail' : 'today', data);
|
||||
return message(c, invalid ? 'Enter a whole number from 0 to 1,000,000,000. This is the total for the date, including any carried progress. Nothing was saved.' : error.message,
|
||||
[row(recover, button(c, 'Today', 'today'))], false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ if (process.env.NODE_ENV !== "production") {
|
||||
const backups = process.env.NODE_ENV === "production" ? startBackups(sqlite, backupConfig(databasePath)) : undefined;
|
||||
if (import.meta.hot) import.meta.hot.dispose(() => backups?.stop());
|
||||
|
||||
const reminders = process.env.NODE_ENV === "production" ? startReminders(db, { token: readDiscordSharingConfig().token, origin: readAuthConfig().origin }) : undefined;
|
||||
const reminders = process.env.NODE_ENV === "production" ? startReminders(db, { token: readDiscordSharingConfig().token, origin: readAuthConfig().origin, commandsAvailable: /^[a-f\d]{64}$/i.test(process.env.DISCORD_PUBLIC_KEY ?? "") && Boolean(readAuthConfig().clientId) }) : undefined;
|
||||
if (import.meta.hot) import.meta.hot.dispose(() => reminders?.stop());
|
||||
|
||||
const app = createApi(db, readAuthConfig(), undefined, undefined, undefined, readDiscordSharingConfig(), { discord: { publicKey: process.env.DISCORD_PUBLIC_KEY ?? "", applicationId: readAuthConfig().clientId, origin: readAuthConfig().origin }, healthy: () => !backups?.state.enabled || (!backups.state.failed && backups.state.lastSuccess > 0) });
|
||||
|
||||
@@ -28,6 +28,7 @@ test("reminders follow local time, send only unfinished habits, and deduplicate
|
||||
await Promise.all([sendDueReminders(f.db, config, transport(sent), now), sendDueReminders(f.db, config, transport(sent), now)]);
|
||||
expect(sent).toHaveLength(1); expect(sent[0]).not.toContain("Private habit name");
|
||||
expect(JSON.parse(sent[0]!).allowed_mentions.parse).toEqual([]);
|
||||
expect(JSON.parse(sent[0]!).components).toBeUndefined();
|
||||
await sendDueReminders(f.db, config, transport(sent), now); expect(sent).toHaveLength(1);
|
||||
f.setTime("2026-09-05T18:00:00Z");
|
||||
await f.json(`/habits/${habit.id}/days/2026-09-05/progress`, "PUT", { done: true });
|
||||
@@ -53,7 +54,7 @@ test("quiet hours defer until their end and DST overlap sends once", async () =>
|
||||
test("opt-out during channel creation cancels the message", async () => {
|
||||
const { f } = await setup(); let posts = 0;
|
||||
try {
|
||||
await sendDueReminders(f.db, config, async url => {
|
||||
await sendDueReminders(f.db, { ...config, commandsAvailable: true }, async url => {
|
||||
if (!url.endsWith("/users/@me/channels")) posts++;
|
||||
f.db.update(reminderSettings).set({ enabled: false }).run();
|
||||
return Response.json({ id: "223456789012345678" });
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { reminderCheckIn } from "../discord/daily";
|
||||
import { createHash } from "node:crypto";
|
||||
import { and, eq, inArray, lt } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../auth";
|
||||
@@ -7,7 +8,7 @@ import { localDate } from "../habits/calendar";
|
||||
import { entriesForDate, inQuietHours, timesForDate } from "./contracts";
|
||||
import type { DiscordFetch } from "../sharing/routes";
|
||||
|
||||
export type ReminderConfig = { token: string; origin: string };
|
||||
export type ReminderConfig = { token: string; origin: string; commandsAvailable?: boolean };
|
||||
function localClock(timestamp: number, timezone: string) {
|
||||
const parts = new Intl.DateTimeFormat("en-GB", { timeZone: timezone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(timestamp);
|
||||
return `${parts.find(part => part.type === "hour")!.value}:${parts.find(part => part.type === "minute")!.value}`;
|
||||
@@ -34,7 +35,7 @@ export async function sendDueReminders(db: AppDatabase, config: ReminderConfig,
|
||||
// Avoid sending an artificial "today" after a backwards timezone change.
|
||||
if (service.today !== localDate(timestamp, currentUser.timezone)) return null;
|
||||
const remaining = service.list().filter(habit => { const day = service.day(habit.id, service.today); return day.due && !day.complete; }).length;
|
||||
return remaining ? { date: service.today, remaining, times, entries: entriesForDate(prefs, service.today) } : null;
|
||||
return remaining ? { date: service.today, remaining, timezone: currentUser.timezone, times, entries: entriesForDate(prefs, service.today) } : null;
|
||||
}
|
||||
const initial = due(); if (!initial) continue;
|
||||
const claims = db.transaction(tx => {
|
||||
@@ -84,6 +85,7 @@ export async function sendDueReminders(db: AppDatabase, config: ReminderConfig,
|
||||
try { response = await request(`https://discord.com/api/v10/channels/${destination.id}/messages`, {
|
||||
method: "POST", headers, redirect: "error", signal: AbortSignal.timeout(15000),
|
||||
body: JSON.stringify({ content: `${prefix}A little time for yourself: ${latest.remaining} ${latest.remaining === 1 ? "habit remains" : "habits remain"} today. Check in: ${config.origin}/`,
|
||||
...(config.commandsAvailable ? { components: reminderCheckIn(db, user.id, latest.timezone, now()) } : {}),
|
||||
allowed_mentions: { parse: [] }, nonce: createHash("sha256").update(ids.sort().join(":")).digest("hex").slice(0, 24), enforce_nonce: true }),
|
||||
}); } catch { update("uncertain"); continue; }
|
||||
if (response.status === 429) { await defer(response); return; }
|
||||
|
||||
Reference in New Issue
Block a user