diff --git a/src/discord/README.md b/src/discord/README.md index 82ba46d..2499a1d 100644 --- a/src/discord/README.md +++ b/src/discord/README.md @@ -107,3 +107,25 @@ sudo docker compose exec -T app bun scripts/register-discord-commands.ts --apply Registration upserts individual global commands and preserves unrelated commands. The public key and interactions URL are one-time setup; adding commands requires rerunning registration after their handlers have deployed. + +## Additional command graphics + +`/today` includes **View day card**, summarizing all due habits in pages of six. +The selected daily check-in and `/history` editor include **View check-in card**, +showing the selected date's requirements, task completion, carryover where +applicable, and a 14-day progress strip ending on that date. `/reminders` includes +**View schedule card**, showing each time slot, weekday/date schedule, quiet-hour +conflicts, and delivery status. Schedule cards retain disabled/unavailable states. +Management forms and `/help` remain text-based because their controls already +communicate the relevant choices. + +These are private snapshots: **Refresh card**, **Toggle theme**, and pagination +request a fresh owned snapshot. Regular check-ins keep their existing immediate +text responses. Graphic actions acknowledge before rendering and attach a PNG to +a new private reply; they never publish to the sharing channel or write progress. +Each image has a text equivalent and attachment description. A render failure +retains the text and navigation. No new command registration or configuration is +needed after deploying this change. + +Generate local visual review artifacts with +`COMMAND_IMAGE_REVIEW_DIR=/tmp/minabot-command-review bun test src/discord/command-graphics.test.ts`. diff --git a/src/discord/command-graphics.test.ts b/src/discord/command-graphics.test.ts new file mode 100644 index 0000000..4c686a3 --- /dev/null +++ b/src/discord/command-graphics.test.ts @@ -0,0 +1,98 @@ +import { afterEach, expect, test } from 'bun:test'; +import { eq } from 'drizzle-orm'; +import { users, reminderSettings, progressEvents } from '../db/schema'; +import { publicUser } from '../auth'; +import { HabitService } from '../habits/service'; +import { ControlStore } from './state'; +import type { Context } from './types'; +import type { DeferredMessage } from './progress'; +import { commandCard } from './command-graphics'; +import { renderCommandCard, commandCardSize } from './command-image'; +import { discordFixture, control, submit } from './test-fixture'; +let f: ReturnType; +afterEach(() => f?.close()); +function setup() { + const jobs: (() => Promise)[] = [], outputs: DeferredMessage[] = []; + f = discordFixture(() => ({ schedule: job => jobs.push(job), complete: async (_i, result) => { outputs.push(result); } })); + return { jobs, outputs, drain: async () => { while (jobs.length) await jobs.shift()!(); return { data: outputs.at(-1)!.message }; } }; +} +const click = (id: string, values?: string[]) => ({ type: 3, message: { id: '123' }, data: { custom_id: id, values } }); +function context(): Context { + const now = Date.parse('2026-09-04T12:00:00Z'), user = publicUser(f.db.select().from(users).where(eq(users.id, 'alice')).get()!); + const service = new HabitService(f.db, user, now); service.sync(); + return { db: f.db, service, now, controls: new ControlStore(f.db, user.id, now), interaction: f.payload({}), origin: f.origin, changed: () => {}, remindersAvailable: true }; +} +for (const guild of [false, true]) test(`day graphics retain private context and refresh without progress writes (${guild ? 'server' : 'DM'})`, async () => { + const e = setup(); + const h = await f.json('/habits', 'POST', { name: 'Read', method: 'count', unit: 'pages', target: 10 }, 201); + let t = await f.run({ data: { name: 'today' } }, guild); + const payload = f.payload(click(control(t, 'View day card')), guild); + expect(await (await f.requestDiscord(payload)).json()).toEqual({ type: 5, data: guild ? { flags: 64 } : {} }); + await f.requestDiscord(payload); expect(e.jobs).toHaveLength(1); + let card = await e.drain(); expect(card.data.content).toContain('0 of 1 habits complete'); + expect(e.outputs[0]!.image!.slice(0, 4)).toEqual(new Uint8Array([137,80,78,71])); + expect(e.outputs[0]!.imageDescription).toContain('Today, at a glance'); + expect(f.db.select().from(progressEvents).all()).toEqual([]); + await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 12 }); + await f.run(click(control(card, 'Refresh card')), guild); card = await e.drain(); + expect(card.data.content).toContain('12/10 pages'); expect(card.data.content).toContain('1 of 1'); + t = await f.run({ data: { name: 'today' } }, guild); + const d = await f.run(click(control(t, 'Choose a habit'), [h.id]), guild); + await f.run(click(control(d, 'View check-in card')), guild); card = await e.drain(); + expect(card.data.content).toContain('12/10 pages'); + expect(f.changed).toHaveLength(0); +}); +test('graphics retain ownership, expiry, pagination and selected historical requirements', async () => { + const e = setup(); const h = await f.json('/habits', 'POST', { name: 'Old target', method: 'count', target: 4 }, 201); + await f.json(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { count: 3 }); + f.setDiscordTime('2026-09-05T12:00:00Z'); + await f.json(`/habits/${h.id}`, 'PATCH', { name: 'New target', target: 10 }); + const history = await f.run({ data: { name: 'history' } }, true); + const form = await f.run(click(control(history, 'Choose a habit'), [h.id]), true); + const day = await f.run(submit(form.data.custom_id, { date: '2026-09-04' }), true); + const id = control(day, 'View check-in card'); + expect((await f.run(click(id), true, f.bob)).data.content).toContain('belongs to another'); + await f.run(click(id), true); const card = await e.drain(); + expect(card.data.content).toContain('Old target'); expect(card.data.content).toContain('3/4'); + f.setDiscordTime('2026-09-06T12:00:00Z'); + expect((await f.run(click(id), true)).data.content).toContain('expired'); +}); +test('six-row cards paginate complete habit and reminder collections', async () => { + const e = setup(); + for (let i = 0; i < 8; i++) await f.json('/habits', 'POST', { name: `Habit ${i}`, method: 'manual' }, 201); + const lastName = (await f.json('/habits')).habits.at(-1).name; + const today = await f.run({ data: { name: 'today' } }); + await f.run(click(control(today, 'View day card'))); let card = await e.drain(); + expect(card.data.content).toContain('Page 1/2'); expect(card.data.content).not.toContain(lastName); + await f.run(click(control(card, 'Next card'))); card = await e.drain(); expect(card.data.content).toContain(lastName); + f.db.insert(reminderSettings).values({ userId: 'alice', enabled: false, quietStart: '22:00', quietEnd: '08:00', updatedAt: 1, + reminders: Array.from({ length: 8 }, (_, i) => ({ type: 'weekly' as const, days: [1, 3, 5], name: `Reminder ${i}`, time: `${String(i + 1).padStart(2, '0')}:00` })) }).run(); + const panel = await f.run({ data: { name: 'reminders' } }); + await f.run(click(control(panel, 'View schedule card'))); card = await e.drain(); + expect(card.data.content).toContain('During quiet hours'); expect(card.data.content).toContain('Mon, Wed, Fri'); + await f.run(click(control(card, 'Next card'))); card = await e.drain(); expect(card.data.content).toContain('Reminder 7'); +}); +test('rendered cards include task progress, future/off states, weekly/date reminders and bounded image dimensions', async () => { + setup(); + const h = await f.json('/habits', 'POST', { name: 'Evening routine', method: 'tasks', tasks: [{ name: 'Read a chapter' }, { name: 'Stretch' }, { name: 'Prepare for tomorrow' }] }, 201); + const tasks = (await f.json(`/habits/${h.id}/tasks`)).tasks; + await f.json(`/habits/${h.id}/days/2026-09-04/tasks/${tasks[0].id}`, 'PUT', { done: true }); + f.db.insert(reminderSettings).values({ userId: 'alice', enabled: true, quietStart: '22:00', quietEnd: '08:00', updatedAt: 1, reminders: [ + { type: 'weekly', name: 'Morning reading', time: '09:00', days: [1,2,3,4,5] }, + { type: 'weekly', name: 'Evening wind-down', time: '21:00', days: [0,1,2,3,4,5,6] }, + { type: 'date', name: 'A little extra encouragement', time: '23:00', date: '2026-09-10' }, + ] }).run(); + for (const [action, data] of [['graphic.today', {}], ['graphic.day', { habitId: h.id, date: '2026-09-04' }], ['graphic.reminders', {}]] as const) { + const model = commandCard(context(), action, data); + for (const theme of ['light','dark'] as const) { + const png = await renderCommandCard(model, theme), b = Buffer.from(png); + expect(b.readUInt32BE(16)).toBe(1200); expect(b.readUInt32BE(20)).toBe(commandCardSize(model).height); expect(b.length).toBeLessThan(4 * 1024 * 1024); + if (process.env.COMMAND_IMAGE_REVIEW_DIR) await Bun.write(`${process.env.COMMAND_IMAGE_REVIEW_DIR}/${action}-${theme}.png`, png); + } + } + const future = commandCard(context(), 'graphic.day', { habitId: h.id, date: '2026-09-05' }); + expect(future.summary).toContain('Future date'); expect(future.history!.at(-1)!.future).toBe(true); + expect(commandCard(context(), 'graphic.day', { habitId: h.id, date: '1970-01-01' }).history).toHaveLength(1); + const long = commandCard(context(), 'graphic.today', {}); long.title = '🌸Very long habit name '.repeat(50); long.rows[0]!.label = '漢字'.repeat(200); + expect((await renderCommandCard(long)).length).toBeLessThan(4 * 1024 * 1024); +}); diff --git a/src/discord/command-graphics.ts b/src/discord/command-graphics.ts new file mode 100644 index 0000000..128f34c --- /dev/null +++ b/src/discord/command-graphics.ts @@ -0,0 +1,62 @@ +import { addDays } from '../habits/calendar'; +import { dateSchema } from '../habits/contracts'; +import { ApiError } from '../habits/service'; +import { ReminderService } from '../reminders/service'; +import { inQuietHours } from '../reminders/contracts'; +import type { Context, ControlData, Reply } from './types'; +import { button, message, row, text } from './ui'; +import { renderCommandCard, type CommandCard, type CardRow } from './command-image'; + +const size = 6; +export function commandCard(c: Context, action: string, data: ControlData): CommandCard { + const base = { owner: c.service.user.displayName, subtitle: c.service.user.timezone }; + let rows: CardRow[], title: string, summary: string, note: string, history: CommandCard['history']; + if (action === 'graphic.today') { + if (data.date && data.date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today for a new card.'); + const days = c.service.list().map(h => c.service.day(h.id, c.service.today)).filter(d => d.due); + title = 'Today, at a glance'; base.subtitle = `${c.service.today} · ${base.subtitle}`; + summary = `${days.filter(d => d.complete).length} of ${days.length} habits complete`; + note = 'Snapshot · Use /today to update progress'; + rows = days.map(d => ({ label: d.name ?? 'Habit', detail: `${d.complete ? 'Complete' : d.value > 0 ? 'In progress' : 'Not started'}${d.carriedFrom ? ` · Carried from ${d.carriedFrom}` : ''}`, value: `${d.value}/${d.target} ${d.unit}`, ratio: d.ratio ?? 0, color: c.service.settings(d.habitId).mainColor })); + } else if (action === 'graphic.day') { + const date = dateSchema.parse(data.date ?? c.service.today); + if (data.today && date !== c.service.today) throw new ApiError(409, 'The day changed. Run /today for a new card.'); + const d = c.service.day(data.habitId!, date), color = c.service.settings(data.habitId!).mainColor; + title = d.name ?? 'Not tracked yet'; base.subtitle = `${date} · ${d.timezone}`; + summary = d.future ? 'Future date · read-only' : !d.requirements ? 'Not tracked on this date' : d.requirements.archived ? 'Archived on this date' : !d.due ? 'Day off · not scheduled' : `${d.value}/${d.target} ${d.unit} · ${d.complete ? 'Complete' : 'Incomplete'}`; + note = 'Snapshot · Historical requirements for the selected date'; + rows = d.method === 'tasks' ? d.tasks.map(t => ({ label: t.name, detail: d.future ? 'Not yet due' : t.done ? 'Complete' : 'Incomplete', ratio: Number(t.done), color })) : [{ label: d.method === 'count' ? 'Recorded amount' : 'Check-in', detail: d.carriedFrom ? `Carried from ${d.carriedFrom}` : d.due ? 'Requirements saved for this date' : 'Read-only date', value: d.due && !d.future ? `${d.value}/${d.target ?? 0}` : '—', ...(!d.future && d.due ? { ratio: d.ratio ?? 0 } : {}), color }]; + history = Array.from({ length: 14 }, (_, i) => addDays(date, i - 13)).filter(day => day >= '1970-01-01').map(day => { const value = c.service.day(data.habitId!, day); return { date: day, ratio: value.ratio, future: value.future, color }; }); + } else if (action === 'graphic.reminders') { + const prefs = new ReminderService(c.db, c.service.user.id, c.remindersAvailable ?? false, c.now).read(); + title = 'Your reminder schedule'; + summary = !prefs.available ? 'Delivery not configured' : !prefs.enabled ? 'Delivery disabled' : `${prefs.reminders.length} reminder time slots`; + note = 'DMs send only while habits remain unfinished'; + base.subtitle = `${base.subtitle} · Quiet hours ${prefs.quietStart === prefs.quietEnd ? 'off' : `${prefs.quietStart}–${prefs.quietEnd}`}`; + rows = [...prefs.reminders].sort((a, b) => a.time.localeCompare(b.time)).map(e => ({ label: `${e.time} · ${e.name || 'Unnamed reminder'}`, detail: `${e.type === 'date' ? e.date : 'Weekly · Mon to Sun'}${inQuietHours(e.time, prefs.quietStart, prefs.quietEnd) ? ' · During quiet hours' : ''}`, ...(e.type === 'weekly' ? { weekdays: e.days } : {}) })); + } else throw new ApiError(404, 'Unknown graphic.'); + const pages = Math.max(1, Math.ceil(rows.length / size)), page = Math.max(0, Math.min(Math.trunc(data.page ?? 0), pages - 1)); + return { ...base, title, summary, note, rows: rows.slice(page * size, (page + 1) * size), page, pages, history }; +} +export function commandGraphicAction(c: Context, action: string, data: ControlData): Reply | undefined { + if (!action.startsWith('graphic.')) return; + if (!c.defer) throw new ApiError(409, 'Run the command again to open a fresh card.'); + // Read one owned, dated snapshot before yielding; these cards never change progress. + const model = commandCard(c, action, data), theme = data.theme ?? 'light'; + const content = [ `**${text(model.title)}** · ${text(model.subtitle)}`, model.summary, + ...model.rows.map(r => `${text(r.label)}${r.value ? ` · ${text(r.value)}` : ''} — ${text(r.detail)}${r.weekdays ? ` (${r.weekdays.map(d => ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d]).join(', ')})` : ''}`), + ...(model.history ? [`Recent days: ${model.history.map(d => `${d.date}: ${d.future ? 'future' : d.ratio === null ? 'off' : `${Math.round(d.ratio * 100)}%`}`).join('; ')}`] : []), + `Page ${model.page + 1}/${model.pages} · ${model.note}` ].join('\n'); + const controls = [ + ...(model.page > 0 ? [button(c, 'Previous card', action, { ...data, page: model.page - 1 })] : []), + ...(model.page + 1 < model.pages ? [button(c, 'Next card', action, { ...data, page: model.page + 1 })] : []), + button(c, 'Refresh card', action, { ...data, page: model.page }), + button(c, 'Toggle theme', action, { ...data, page: model.page, theme: theme === 'light' ? 'dark' : 'light' }), + ]; + const reply = message(c, content, [row(...controls)], false); + c.defer(async () => { + try { return { message: reply.data as import('./types').Message, image: await renderCommandCard(model, theme), imageDescription: `${model.title}. ${model.summary}. ${model.subtitle}. Page ${model.page + 1} of ${model.pages}.` }; } + catch { return { message: { ...(reply.data as import('./types').Message), content: `The graphic could not be rendered.\n${content}`.slice(0, 2000) } }; } + }); + return { type: 5, data: c.interaction.guild_id ? { flags: 64 } : {} }; +} diff --git a/src/discord/command-image.ts b/src/discord/command-image.ts new file mode 100644 index 0000000..ea8282c --- /dev/null +++ b/src/discord/command-image.ts @@ -0,0 +1,65 @@ +import { createCanvas } from '@napi-rs/canvas'; +import '../sharing/live-card-image'; +import { themePalette, type Theme } from '../lib/theme'; +import { habitProgressColor } from '../lib/habit-palette'; + +export type CardRow = { label: string; detail: string; value?: string; ratio?: number; color?: string; weekdays?: number[] }; +export type CommandCard = { title: string; owner: string; subtitle: string; summary: string; note: string; rows: CardRow[]; page: number; pages: number; history?: { date: string; ratio: number | null; future: boolean; color: string }[] }; +export const commandCardSize = (data: CommandCard) => ({ width: 1200, height: 350 + Math.max(1, data.rows.length) * 116 + (data.history ? 164 : 0) + 100 }); +/** Fixed-size rows and text keep every page readable, including very large accounts. */ +export async function renderCommandCard(data: CommandCard, theme: Theme = 'light') { + if (data.rows.length > 6 || (data.history && data.history.length > 14)) throw new Error('Paginate command cards before rendering.'); + const { width, height } = commandCardSize(data), canvas = createCanvas(width, height), ctx = canvas.getContext('2d'); + const p = themePalette[theme]; + const rect = (x: number, y: number, w: number, h: number, color: string, r = 0) => { ctx.fillStyle = color; ctx.beginPath(); ctx.roundRect(x, y, w, h, r); ctx.fill(); }; + const text = (value: string, x: number, y: number, size: number, maxWidth: number, color: string = p.ink, serif = false) => { + ctx.font = `${size}px "${serif ? 'Instrument Serif' : 'Mina Sans'}"`; ctx.fillStyle = color; + const characters = Array.from(value.replace(/[\r\n\t]/g, ' ')); let fitted = characters.join(''); + while (characters.length && ctx.measureText(fitted).width > maxWidth) { characters.pop(); fitted = characters.join('') + '…'; } + ctx.fillText(fitted, x, y); + }; + rect(0, 0, width, height, p.paper); rect(0, 0, width, 8, '#285d49'); + text(data.owner, 64, 72, 26, 760); text('Mina Habits.', 938, 76, 38, 205, p.ink, true); + text(data.title, 64, 150, 60, 1072, p.ink, true); + text(data.subtitle, 64, 200, 25, 1072, p.muted); + text(data.summary, 64, 270, 38, 1072); + rect(64, 312, 1072, 1, p.rule); + if (!data.rows.length) text('Nothing scheduled here.', 64, 392, 30, 1072, p.muted); + data.rows.forEach((r, index) => { + const y = 346 + index * 116, color = r.color ?? '#285d49'; + rect(64, y, 6, 88, color, 3); + const valueWidth = r.weekdays ? 0 : 270; + text(r.label, 92, y + 30, 30, 1016 - valueWidth); + if (r.value) text(r.value, 860, y + 30, 25, 270); + text(r.detail, 92, y + 68, 22, r.weekdays ? 580 : 1016, p.muted); + if (r.weekdays) { + ['M', 'T', 'W', 'T', 'F', 'S', 'S'].forEach((label, i) => { + const active = r.weekdays!.includes((i + 1) % 7), x = 732 + i * 58; + rect(x, y + 45, 46, 35, active ? p.avatar : p.empty, 6); + text(label, x + 12, y + 71, 22, 28, active ? p.ink : p.muted); + if (active) rect(x + 12, y + 83, 22, 3, habitProgressColor(color, 1, theme), 1); + }); + } else if (r.ratio !== undefined) { + rect(92, y + 87, 1044, 6, p.empty, 3); + if (r.ratio > 0) rect(92, y + 87, 1044 * Math.max(0, Math.min(1, r.ratio)), 6, habitProgressColor(color, 1, theme), 3); + } + }); + const bottom = 350 + Math.max(1, data.rows.length) * 116; + if (data.history) { + text(`Recent days · ${data.history[0]?.date ?? ''} to ${data.history.at(-1)?.date ?? ''}`, 64, bottom + 26, 23, 1072, p.muted); + data.history.forEach((day, i) => { + const x = 64 + i * 77; + const fill = day.future || day.ratio === null ? p.avatar : !day.ratio ? p.empty : habitProgressColor(day.color, day.ratio, theme); + rect(x, bottom + 47, 62, 42, fill, 6); + // State symbols retain meaning without color; draw them without font dependence. + ctx.strokeStyle = p.ink; ctx.lineWidth = 2; + if (day.future || day.ratio === null) { ctx.beginPath(); ctx.moveTo(x + 24, bottom + 68); ctx.lineTo(x + 38, bottom + 68); ctx.stroke(); } + text(day.date.slice(8), x + 15, bottom + 119, 20, 45, p.muted); + if (i === data.history!.length - 1) rect(x, bottom + 127, 62, 3, p.ink, 1); + }); + } + rect(64, height - 92, 1072, 1, p.rule); + text(data.note, 64, height - 48, 21, 860, p.muted); + text(`${data.page + 1} / ${data.pages}`, 1035, height - 48, 21, 100, p.muted); + return new Uint8Array(await canvas.encode('png')); +} diff --git a/src/discord/daily.ts b/src/discord/daily.ts index e677ea5..812d897 100644 --- a/src/discord/daily.ts +++ b/src/discord/daily.ts @@ -18,7 +18,7 @@ export function today(c: Context, data: ControlData = {}): Reply { const all = c.service.list().map(h => c.service.day(h.id, c.service.today)).filter(d => d.due); 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', { date: c.service.today, page }, 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'), button(c, 'View day card', 'graphic.today', { date: c.service.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; @@ -36,7 +36,7 @@ export function detail(c: Context, data: ControlData): Reply { controls.push(...pages(c, 'daily.detail', data, d.tasks.length)); } } - controls.push(row(button(c, 'Refresh', 'daily.detail', data), button(c, 'Today', 'today'))); + controls.push(row(button(c, 'Refresh', 'daily.detail', data), button(c, 'Today', 'today'), button(c, 'View check-in card', 'graphic.day', { ...data, page: 0 }))); 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); } function action(c: Context, action: string, data: ControlData): Reply | undefined { diff --git a/src/discord/preferences.ts b/src/discord/preferences.ts index cb9ae04..ecdd915 100644 --- a/src/discord/preferences.ts +++ b/src/discord/preferences.ts @@ -46,7 +46,7 @@ export function preferences(c: Context, command: string, data: ControlData = {}) ...(visible.length ? [select(c, 'Choose a reminder', 'prefs.select', state, visible.map((g, i) => ({ id: String(page * 10 + i), name: g.name, description: `${g.times.join(', ')} · ${schedule(g.entry)}` })))] : []), ...pages(c, 'prefs.list', { page }, all.length), row(button(c, 'Add reminder', 'prefs.edit', { ...state, index: undefined }), button(c, prefs.enabled ? 'Disable delivery' : 'Enable delivery', 'prefs.toggle', { ...state, done: !prefs.enabled }), button(c, 'Quiet hours', 'prefs.quiet', state)), - row(button(c, 'Settings', 'prefs.settings'), button(c, 'Refresh', 'prefs.list', { page })), + row(button(c, 'Settings', 'prefs.settings'), button(c, 'Refresh', 'prefs.list', { page }), button(c, 'View schedule card', 'graphic.reminders')), ]); } function edit(c: Context, data: ControlData): Reply { diff --git a/src/discord/progress.ts b/src/discord/progress.ts index 421f06c..f1bb0a1 100644 --- a/src/discord/progress.ts +++ b/src/discord/progress.ts @@ -11,7 +11,7 @@ export type ProgressOperations = { sharing: Pick, 'send' | 'destination'>; live: Pick, 'state' | 'start' | 'pause'>; }; -export type DeferredMessage = { message: Message; image?: Uint8Array }; +export type DeferredMessage = { message: Message; image?: Uint8Array; imageDescription?: string }; const fingerprint = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex'); function imageData(c: Context, data: ControlData) { const result = liveCardData(c.db, c.service.user.id, c.now); diff --git a/src/discord/routes.ts b/src/discord/routes.ts index 31a1a2a..1905933 100644 --- a/src/discord/routes.ts +++ b/src/discord/routes.ts @@ -15,6 +15,7 @@ import { preferences, preferencesAction } from './preferences'; import { progress, progressAction, type DeferredMessage, type ProgressOperations } from './progress'; import { message } from './ui'; import { help } from './help'; +import { commandGraphicAction } from './command-graphics'; export type CommandConfig = { publicKey: string; applicationId: string; origin: string; remindersAvailable?: boolean }; export type DeferredDispatch = { @@ -25,7 +26,7 @@ export type DeferredDispatch = { /** Edit the deferred original exactly once. Never retry an uncertain network result. */ export async function completeDeferred(i: import('./types').Interaction, result: DeferredMessage) { const body = new FormData(); - const payload = { ...result.message, flags: undefined, allowed_mentions: { parse: [] }, attachments: result.image ? [{ id: 0, filename: 'mina-progress.png', description: result.message.content?.slice(0, 1024) }] : [] }; + const payload = { ...result.message, flags: undefined, allowed_mentions: { parse: [] }, attachments: result.image ? [{ id: 0, filename: 'mina-progress.png', description: (result.imageDescription ?? result.message.content)?.slice(0, 1024) }] : [] }; body.set('payload_json', JSON.stringify(payload)); if (result.image) body.set('files[0]', new Blob([new Uint8Array(result.image)], { type: 'image/png' }), 'mina-progress.png'); const response = await fetch(`https://discord.com/api/v10/webhooks/${i.application_id}/${encodeURIComponent(i.token!)}/messages/@original`, { method: 'PATCH', body, redirect: 'error', signal: AbortSignal.timeout(15000) }); @@ -74,7 +75,7 @@ export function createDiscordRoutes(db: AppDatabase, config: CommandConfig, now else if (i.type === 2 && i.data?.name === 'history') result = history(ctx); else if ([3, 5].includes(i.type)) { const state = controls.read(i.data?.custom_id ?? ''); - result = progressAction(ctx, state.action, state.payload) ?? preferencesAction(ctx, state.action, state.payload) ?? historyAction(ctx, state.action, state.payload) ?? dailyAction(ctx, state.action, state.payload) ?? managementAction(ctx, state.action, state.payload) ?? message(ctx, 'Run /help to see available commands.', [], false); + result = commandGraphicAction(ctx, state.action, state.payload) ?? progressAction(ctx, state.action, state.payload) ?? preferencesAction(ctx, state.action, state.payload) ?? historyAction(ctx, state.action, state.payload) ?? 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); } catch (error) { if (!(error instanceof ApiError) && !(error instanceof ZodError)) throw error;