feat: add signed Discord command foundation and daily check-ins

This commit is contained in:
syntaxbullet
2026-09-08 08:15:22 +02:00
parent 8e846fa11b
commit 4f38bf1431
17 changed files with 1556 additions and 3 deletions

View File

@@ -1,3 +1,4 @@
import { createDiscordRoutes, type CommandConfig } from "./discord/routes";
import { createLiveDiscord } from "./sharing/live-discord";
import { createReminderRoutes } from "./reminders/routes";
import { errorDetails } from "./ops/monitor";
@@ -12,7 +13,7 @@ import type { FetchDiscord } from "./auth/discord";
import { createSharingRoutes, type DiscordFetch } from "./sharing/routes";
import type { DiscordSharingConfig } from "./sharing/config";
export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDiscord, now?: () => number, discordRequest?: DiscordFetch, sharingConfig: DiscordSharingConfig = { token: "", channelId: "" }, operations: { healthy?: () => boolean; log?: (event: Record<string, unknown>) => void } = {}) {
export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDiscord, now?: () => number, discordRequest?: DiscordFetch, sharingConfig: DiscordSharingConfig = { token: "", channelId: "" }, operations: { healthy?: () => boolean; log?: (event: Record<string, unknown>) => void; discord?: CommandConfig } = {}) {
const app = new Hono<AuthEnv>();
app.use("*", async (c, next) => {
const requestId = crypto.randomUUID();
@@ -32,6 +33,7 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi
if (operations.healthy && !operations.healthy()) return c.json({ status: "degraded" }, 503);
return c.json({ status: "ok" });
});
app.route("/api/discord/interactions", createDiscordRoutes(db, operations.discord ?? { publicKey: "", applicationId: config.clientId, origin: config.origin }, now ?? Date.now, liveSharing.changed));
app.route("/api/sharing/live", liveSharing.routes);
app.route("/api/auth", auth.routes);
app.route("/api/reminders", createReminderRoutes(db, auth, Boolean(sharingConfig.token), now ?? Date.now));

View File

@@ -26,7 +26,7 @@ export function normalizeTimezone(value: unknown): string {
}
}
function publicUser(user: typeof users.$inferSelect): PublicUser {
export function publicUser(user: typeof users.$inferSelect): PublicUser {
return {
id: user.id,
discordId: user.discordId,

View File

@@ -101,3 +101,15 @@ export const liveDiscordCards = sqliteTable('live_discord_cards', {
retryAt: integer('retry_at'),
updatedAt: integer('updated_at').notNull(),
});
// Short-lived UI state and replay receipts; no Discord interaction tokens are persisted.
export const discordControls = sqliteTable('discord_controls', {
id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
action: text('action').notNull(), payload: text('payload', { mode: 'json' }).$type<import('../discord/types').ControlData>().notNull(),
expiresAt: integer('expires_at').notNull(), used: integer('used', { mode: 'boolean' }).notNull().default(false),
}, t => [index('discord_controls_expiry_idx').on(t.expiresAt)]);
export const discordReceipts = sqliteTable('discord_receipts', {
id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
response: text('response', { mode: 'json' }).$type<import('../discord/types').Reply>().notNull(),
expiresAt: integer('expires_at').notNull(),
}, t => [index('discord_receipts_expiry_idx').on(t.expiresAt)]);

21
src/discord/README.md Normal file
View File

@@ -0,0 +1,21 @@
# Discord interface
Signed HTTP interactions enter `/api/discord/interactions`. The raw request body
is verified with the application's Ed25519 public key and a five-minute timestamp
window. `DISCORD_PUBLIC_KEY` enables the endpoint; `DISCORD_CLIENT_ID` identifies
the application. Commands are global with contexts `[0, 1]` (server and bot DM).
`HabitService` remains the shared domain implementation. Verified Discord user IDs
resolve the same account as website OAuth; new accounts start in UTC. Server
responses are ephemeral. Buttons/forms carry opaque persisted owner-bound state,
expire after 15 minutes, and mutation controls are consumed after successful use.
Interaction IDs retain replay receipts for 24 hours. Neither table stores tokens.
`daily.ts` implements the initial `/today` workflow. `types.ts`, `ui.ts`, and
`state.ts` provide the shared feature building blocks. Feature tasks extend command
registration and route dispatch while keeping their behavior in separate modules.
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.

4
src/discord/commands.ts Normal file
View File

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

50
src/discord/daily.ts Normal file
View File

@@ -0,0 +1,50 @@
import { ApiError } from '../habits/service';
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';
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);
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');
}
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])]) };
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 === '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}` }))));
controls.push(...pages(c, 'daily.detail', data, d.tasks.length));
}
}
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);
}
export function dailyAction(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.detail') return detail(c, data);
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 (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.');
c.service.completeTask(data.habitId!, data.date!, task.taskId, !task.done);
} else {
const amount = fields(c.interaction).amount;
c.service.progress(data.habitId!, data.date!, progressInput.parse(data.done !== undefined ? { done: data.done } : { count: amount === undefined ? data.value : /^\d+$/.test(amount) ? Number(amount) : NaN }));
}
c.controls.consume(c.interaction.data!.custom_id!); c.changed(); return detail(c, data);
}
}

View File

@@ -0,0 +1,47 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { discordFixture, control, submit } from './test-fixture';
import { commands } from './commands';
let f: ReturnType<typeof discordFixture>;
afterEach(() => f?.close());
describe('signed Discord interactions', () => {
test('registers commands globally for servers and bot DMs', () => {
for (const c of commands) { expect(c.contexts).toEqual([0, 1]); expect(c.integration_types).toEqual([0]); }
});
test('rejects forged and old signatures and answers Discord PING', async () => {
f = discordFixture();
expect((await f.requestDiscord(f.payload({ type: 1 }), { 'X-Signature-Ed25519': '0'.repeat(128) })).status).toBe(401);
expect((await f.requestDiscord(f.payload({ type: 1 }), { 'X-Signature-Timestamp': '1000000000' })).status).toBe(401);
expect(await f.run({ type: 1 })).toEqual({ type: 1 });
expect((await f.requestDiscord(f.payload({ application_id: 'wrong' }))).status).toBe(400);
});
for (const guild of [false, true]) test(`records progress from ${guild ? 'server' : 'DM'} and shares website state`, async () => {
f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Reading', method: 'count', target: 10, unit: 'pages' }, 201);
const today = await f.run({ data: { name: 'today' } }, guild);
expect(today.data.flags).toBe(guild ? 64 : undefined);
const detail = await f.run({ type: 3, message: { id: '123' }, data: { custom_id: control(today, 'Choose a habit'), values: [h.id] } }, guild);
const click = f.payload({ type: 3, message: { id: '123' }, data: { custom_id: control(detail, '+1') } }, guild);
expect((await (await f.requestDiscord(click)).json()).type).toBe(7);
f.restart(); await f.requestDiscord(click);
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(1);
expect(f.changed).toEqual(['alice']);
const stale = await f.run({ type: 3, data: { custom_id: control(detail, 'Enter amount') } }, guild);
const failed = await f.run(submit(stale.data.custom_id, { amount: '7' }), guild);
expect(failed.data.content).toContain('Progress changed');
});
test('owner-bound buttons and habit IDs prevent cross-account access', async () => {
f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Private', method: 'manual' }, 201);
const a = await f.run({ data: { name: 'today' } });
const blocked = await f.run({ type: 3, data: { custom_id: control(a, 'Choose a habit'), values: [h.id] } }, true, f.bob);
expect(blocked.data.content).toContain('belongs to another user'); expect(blocked.data.flags).toBe(64);
const b = await f.run({ data: { name: 'today' } }, false, f.bob);
expect(b.data.content).not.toContain('Private');
});
test('old daily buttons cannot correct history accidentally', async () => {
f = discordFixture(); const h = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201);
const t = await f.run({ data: { name: 'today' } });
const d = await f.run({ type: 3, data: { custom_id: control(t, 'Choose a habit'), values: [h.id] } });
f.setDiscordTime('2026-09-05T12:00:00Z');
expect((await f.run({ type: 3, data: { custom_id: control(d, 'Mark complete') } })).data.content).toContain('expired');
expect((await f.json(`/habits/${h.id}/days/2026-09-04`)).value).toBe(0);
});
});

62
src/discord/routes.ts Normal file
View File

@@ -0,0 +1,62 @@
import { createPublicKey, verify } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { bodyLimit } from 'hono/body-limit';
import { ZodError } from 'zod';
import { publicUser, type AppDatabase } from '../auth';
import { users, discordReceipts } from '../db/schema';
import { ApiError, HabitService } from '../habits/service';
import { ControlStore } from './state';
import { interactionSchema, type Context, type Reply } from './types';
import { dailyAction, today } from './daily';
import { message } from './ui';
export type CommandConfig = { publicKey: string; applicationId: string; origin: string };
export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now = Date.now, changed: (userId: string) => void = () => {}) {
const app = new Hono();
const key = /^[a-f\d]{64}$/i.test(config.publicKey) ? createPublicKey({ key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), Buffer.from(config.publicKey, 'hex')]), format: 'der', type: 'spki' }) : null;
app.use('*', bodyLimit({ maxSize: 64 * 1024 }));
app.post('/', async c => {
c.header('Cache-Control', 'no-store');
if (!key) return c.json({ error: 'Discord commands are not configured.' }, 503);
const signature = c.req.header('X-Signature-Ed25519') ?? '', timestamp = c.req.header('X-Signature-Timestamp') ?? '';
const body = await c.req.text();
if (!/^\d{10,12}$/.test(timestamp) || Math.abs(now() - Number(timestamp) * 1000) > 300000 || !/^[a-f\d]{128}$/i.test(signature) || !verify(null, Buffer.from(timestamp + body), key, Buffer.from(signature, 'hex')))
return c.json({ error: 'Invalid request signature.' }, 401);
let value: unknown;
try { value = JSON.parse(body); } catch { return c.json({ error: "Malformed JSON." }, 400); }
const parsed = interactionSchema.safeParse(value);
if (!parsed.success || parsed.data.application_id !== config.applicationId) return c.json({ error: 'Invalid interaction.' }, 400);
const i = parsed.data;
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);
const reply = db.transaction((): Reply => {
const existing = db.select().from(users).where(eq(users.discordId, profile.id)).get();
const userId = existing?.id ?? crypto.randomUUID();
if (!existing) db.insert(users).values({ id: userId, discordId: profile.id, username: profile.username, globalName: profile.global_name, avatarHash: profile.avatar, createdAt: now(), updatedAt: now() }).run();
const receipt = db.select().from(discordReceipts).where(eq(discordReceipts.id, i.id)).get();
if (receipt && receipt.expiresAt > now()) return receipt.response;
const user = publicUser(db.select().from(users).where(eq(users.id, userId)).get()!);
const controls = new ControlStore(db, userId, now()); controls.prune();
const service = new HabitService(db, user, now()); service.sync();
const ctx: Context = { db, service, controls, interaction: i, now: now(), origin: config.origin, changed: () => changed(userId) };
let result: Reply;
try {
if (i.type === 4) result = { type: 8, data: { choices: [] } };
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);
} 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);
}
db.insert(discordReceipts).values({ id: i.id, userId, response: result, expiresAt: now() + 86400000 }).run();
return result;
});
return c.json(reply);
});
return app;
}

29
src/discord/state.ts Normal file
View File

@@ -0,0 +1,29 @@
import { randomBytes } from 'node:crypto';
import { eq, lt } from 'drizzle-orm';
import type { AppDatabase } from '../auth';
import { discordControls, discordReceipts } from '../db/schema';
import { ApiError } from '../habits/service';
import type { ControlData } from './types';
export class ControlStore {
constructor(readonly db: AppDatabase, readonly userId: string, readonly now: number) {}
create(action: string, payload: ControlData = {}, lifetime = 15 * 60000) {
const id = randomBytes(18).toString('base64url');
this.db.insert(discordControls).values({ id, userId: this.userId, action, payload, expiresAt: this.now + lifetime }).run();
return `mina:${id}`;
}
read(customId: string) {
const row = this.db.select().from(discordControls).where(eq(discordControls.id, customId.replace(/^mina:/, ''))).get();
if (!customId.startsWith('mina:') || !row || row.userId !== this.userId || row.expiresAt <= this.now || row.used)
throw new ApiError(409, 'This control expired or belongs to another user. Run the command again.');
return row;
}
consume(customId: string) {
const row = this.read(customId);
this.db.update(discordControls).set({ used: true }).where(eq(discordControls.id, row.id)).run();
}
prune() {
this.db.delete(discordControls).where(lt(discordControls.expiresAt, this.now)).run();
this.db.delete(discordReceipts).where(lt(discordReceipts.expiresAt, this.now)).run();
}
}

View File

@@ -0,0 +1,35 @@
import { generateKeyPairSync, sign } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { fixture } from '../habits/test-fixture';
import { users } from '../db/schema';
import { createDiscordRoutes } from './routes';
import type { Interaction } from './types';
export function discordFixture() {
const f = fixture();
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
const key = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('hex');
let time = Date.parse('2026-09-04T12:00:00Z'), sequence = 10000000000000000n;
const alice = '111111111111111111', bob = '222222222222222222', applicationId = '333333333333333333';
f.db.update(users).set({ discordId: alice }).where(eq(users.id, 'alice')).run();
f.db.update(users).set({ discordId: bob }).where(eq(users.id, 'bob')).run();
const changed: string[] = [];
let app = createDiscordRoutes(f.db, { publicKey: key, applicationId, origin: f.origin }, () => time, id => changed.push(id));
function payload(data: Partial<Interaction>, guild = false, user = alice): Interaction {
const profile = { id: user, username: user === alice ? 'alice' : 'bob' };
return { id: String(++sequence), application_id: applicationId, type: 2, ...(guild ? { guild_id: '444444444444444444', member: { user: profile } } : { user: profile }), ...data };
}
async function request(i: Interaction, headers: Record<string, string> = {}) {
const body = JSON.stringify(i), timestamp = String(time / 1000);
return app.request('http://localhost/', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Signature-Timestamp': timestamp, 'X-Signature-Ed25519': sign(null, Buffer.from(timestamp + body), privateKey).toString('hex'), ...headers }, body });
}
async function run(data: Partial<Interaction>, guild = false, user = alice): Promise<any> { return (await request(payload(data, guild, user))).json(); }
return { ...f, alice, bob, changed, payload, requestDiscord: request, run, restart: () => { app = createDiscordRoutes(f.db, { publicKey: key, applicationId, origin: f.origin }, () => time, id => changed.push(id)); }, setDiscordTime: (value: string) => { time = Date.parse(value); f.setTime(value); } };
}
export function control(reply: any, label: string): string {
const item = reply.data.components.flatMap((r: any) => r.components ?? []).find((c: any) => c.label === label || c.placeholder === label);
if (!item) throw new Error(`Missing control ${label}: ${JSON.stringify(reply)}`);
return item.custom_id;
}
export function submit(custom_id: string, values: Record<string, string>) {
return { type: 5, message: { id: '123' }, data: { custom_id, components: Object.entries(values).map(([custom_id, value]) => ({ type: 1, components: [{ type: 4, custom_id, value }] })) } };
}

23
src/discord/types.ts Normal file
View File

@@ -0,0 +1,23 @@
import { z } from 'zod';
import type { AppDatabase } from '../auth';
import type { HabitService } from '../habits/service';
import type { ControlStore } from './state';
const option: z.ZodType<Option> = z.lazy(() => z.object({ name: z.string(), type: z.number(), value: z.union([z.string(), z.number(), z.boolean()]).optional(), focused: z.boolean().optional(), options: z.array(option).optional() }));
export type Option = { name: string; type: number; value?: string | number | boolean; focused?: boolean; options?: Option[] };
const profile = z.object({ id: z.string().regex(/^\d{17,20}$/), username: z.string().max(100), global_name: z.string().max(100).nullish(), avatar: z.string().max(100).nullish() });
const component: z.ZodType<ComponentInput> = z.lazy(() => z.object({ type: z.number(), custom_id: z.string().optional(), value: z.string().optional(), values: z.array(z.string()).optional(), components: z.array(component).optional() }));
type ComponentInput = { type: number; custom_id?: string; value?: string; values?: string[]; components?: ComponentInput[] };
export const interactionSchema = z.object({ id: z.string().regex(/^\d{17,20}$/), application_id: z.string(), type: z.number(), token: z.string().max(2048).optional(), guild_id: z.string().optional(), user: profile.optional(), member: z.object({ user: profile }).optional(), message: z.object({ id: z.string() }).optional(), data: z.object({ name: z.string().optional(), custom_id: z.string().max(100).optional(), values: z.array(z.string()).optional(), options: z.array(option).optional(), components: z.array(component).optional() }).optional() });
export type Interaction = z.infer<typeof interactionSchema>;
export type DiscordComponent = { type: number; [key: string]: unknown };
export type Message = { content?: string; components?: DiscordComponent[]; flags?: number; allowed_mentions?: { parse: string[] }; attachments?: unknown[]; embeds?: unknown[] };
export type Reply = { type: number; data?: Message | { custom_id: string; title: string; components: DiscordComponent[] } | { choices: { name: string; value: string }[] } };
export type ControlData = { habitId?: string; taskId?: string; date?: string; today?: boolean; page?: number; mode?: string; method?: string; expected?: string; value?: number; done?: boolean; revision?: number; index?: number; theme?: 'light' | 'dark'; deliveryId?: string };
export type Context = { db: AppDatabase; service: HabitService; controls: ControlStore; interaction: Interaction; now: number; origin: string; changed: () => void };
export function fields(i: Interaction) {
const result: Record<string, string> = {};
const walk = (parts: ComponentInput[]) => parts.forEach(c => { if (c.custom_id && c.value !== undefined) result[c.custom_id] = c.value; if (c.components) walk(c.components); });
walk(i.data?.components ?? []); return result;
}
export const options = (i: Interaction) => Object.fromEntries((i.data?.options ?? []).map(o => [o.name, o.value]));

21
src/discord/ui.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { Context, ControlData, DiscordComponent, Reply } from './types';
export const text = (s: string) => s.replace(/([\\`*_~|>])/g, '\\$1').replace(/@/g, '@\u200b');
export const row = (...components: DiscordComponent[]): DiscordComponent => ({ type: 1, components });
export const button = (c: Context, label: string, action: string, payload: ControlData = {}, style = 2): DiscordComponent => ({ type: 2, label, style, custom_id: c.controls.create(action, payload) });
export function message(c: Context, content: string, components: DiscordComponent[] = [], update = true): Reply {
const edit = update && !!c.interaction.message && [3, 5].includes(c.interaction.type);
return { type: edit ? 7 : 4, data: { content: content.slice(0, 2000), components, allowed_mentions: { parse: [] }, ...(!edit && c.interaction.guild_id ? { flags: 64 } : {}) } };
}
export type Field = { id: string; label: string; value?: string; placeholder?: string; required?: boolean; max?: number };
export function modal(c: Context, title: string, action: string, data: ControlData, fields: Field[]): Reply {
return { type: 9, data: { custom_id: c.controls.create(action, data), title: title.slice(0, 45), components: fields.map(f => row({ type: 4, custom_id: f.id, label: f.label.slice(0, 45), style: 1, required: f.required ?? true, max_length: f.max ?? 200, ...(f.value !== undefined ? { value: f.value } : {}), ...(f.placeholder ? { placeholder: f.placeholder.slice(0, 100) } : {}) })) } };
}
export function select(c: Context, placeholder: string, action: string, data: ControlData, items: { id: string; name: string; description?: string }[]) {
return row({ type: 3, custom_id: c.controls.create(action, data), placeholder, options: items.slice(0, 25).map(i => ({ label: i.name.slice(0, 100) || 'Untitled', value: i.id, ...(i.description ? { description: i.description.slice(0, 100) } : {}) })) });
}
export function pages(c: Context, action: string, data: ControlData, total: number, size = 10) {
const page = data.page ?? 0, buttons: DiscordComponent[] = [];
if (page > 0) buttons.push(button(c, 'Previous', action, { ...data, page: page - 1 }));
if ((page + 1) * size < total) buttons.push(button(c, 'Next', action, { ...data, page: page + 1 }));
return buttons.length ? [row(...buttons)] : [];
}

View File

@@ -20,7 +20,7 @@ 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;
if (import.meta.hot) import.meta.hot.dispose(() => reminders?.stop());
const app = createApi(db, readAuthConfig(), undefined, undefined, undefined, readDiscordSharingConfig(), { healthy: () => !backups?.state.enabled || (!backups.state.failed && backups.state.lastSuccess > 0) });
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) });
if (import.meta.hot) import.meta.hot.dispose(() => app.stopLiveSharing());