feat: add live Discord sharing functionality with dynamic habit progress images

- Implemented live card image generation for user habits using canvas.
- Created API endpoints for starting and managing live Discord sharing sessions.
- Added tests for live Discord sharing to ensure functionality and reliability.
- Introduced CSS styles for the live Discord sharing interface.
- Enhanced error handling and state management for Discord message updates.
This commit is contained in:
syntaxbullet
2026-09-07 11:36:47 +02:00
parent 00729096bf
commit c7aa5a1c65
22 changed files with 2792 additions and 7 deletions

View File

@@ -405,3 +405,11 @@ The disposable `SHARE_PREVIEW=1 bun scripts/dashboard-preview.ts` mode also runs
reminder worker against a local transport stub. Set a reminder to `14:00` (its fixed
Europe/Belgrade time), leave a habit unfinished, and inspect delivery status in
settings or `/__preview/reminder`. No real Discord messages are sent in this mode.
### Live Discord image
Open **Share progress → Live image · 16:9** to preview/download a 1920×1080 PNG with every active habit and a rolling 30-day history. **Share live image** posts the attachment through the configured bot; later changes replace the attachment on that same Discord message after a two-second trailing debounce. There is no public live link. Display name, habit names, and progress are shared; task details remain private. Newly created active habits are included automatically.
Rendering and updates run on the server even when the dashboard is closed. Identical card content skips Discord edits. Message IDs, themes, and retry state persist in SQLite; known messages resume after restart. Rate-limited edits honor Discord's retry delay. When an update confirms the old message was deleted, the bot posts a replacement and saves its new message ID. Missing channels or permissions do not trigger replacement posts. An unconfirmed initial or replacement post is never automatically repeated. **Stop updates** leaves the last image in Discord; **Resume updates** edits the original message again.
Development uses `bun --watch` so source edits restart the process and recreate the background worker; in-process hot reload can leave its timers stalled. The worker assumes one Bun API process per database. Reverse proxies need no special streaming configuration. Run the normal migrations/build/start workflow to install the live-image table and bundle its font; the server uses `@napi-rs/canvas` for PNG rendering. The previous experimental public-link table is removed by migration 0010.

View File

@@ -1,5 +1,5 @@
import tailwind from "bun-plugin-tailwind";
import { rm } from "node:fs/promises";
import { rm, mkdir, copyFile } from "node:fs/promises";
import path from "node:path";
const outdir = path.join(process.cwd(), "dist");
@@ -11,6 +11,7 @@ const result = await Bun.build({
plugins: [tailwind],
minify: true,
target: "bun",
external: ["@napi-rs/canvas"],
sourcemap: "linked",
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
@@ -24,3 +25,6 @@ if (!result.success) {
for (const output of result.outputs) {
console.log(` ${path.relative(process.cwd(), output.path)} ${(output.size / 1024).toFixed(1)} KB`);
}
await mkdir(path.join(outdir, "fonts"), { recursive: true });
await copyFile("src/assets/fonts/InstrumentSerif-Regular.ttf", path.join(outdir, "fonts/InstrumentSerif-Regular.ttf"));

View File

@@ -5,6 +5,7 @@
"": {
"name": "mina-habits",
"dependencies": {
"@napi-rs/canvas": "^1.0.8",
"@nivo/calendar": "^0.99.0",
"bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1",
@@ -89,6 +90,30 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@napi-rs/canvas": ["@napi-rs/canvas@1.0.8", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.8", "@napi-rs/canvas-darwin-arm64": "1.0.8", "@napi-rs/canvas-darwin-x64": "1.0.8", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.8", "@napi-rs/canvas-linux-arm64-gnu": "1.0.8", "@napi-rs/canvas-linux-arm64-musl": "1.0.8", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.8", "@napi-rs/canvas-linux-x64-gnu": "1.0.8", "@napi-rs/canvas-linux-x64-musl": "1.0.8", "@napi-rs/canvas-win32-arm64-msvc": "1.0.8", "@napi-rs/canvas-win32-x64-msvc": "1.0.8" } }, "sha512-/SaLcvlqGWdm0HSCWMgHu7cjJiQXfP8/mOY+6dUyV9flQz7sPBBZ+ed2zYtoukojPmxOaL7bm+d/G4GeWWoN7g=="],
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.8", "", { "os": "android", "cpu": "arm64" }, "sha512-5+nkh8i3gt6lqS/d2jTZ1xAn6tdgtB4Lf1mW6T0Qm5/rXNwBuV1sAEyLEWan5o9gJPU/GuvHR3rvSeZ+FaGrbw=="],
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7jQ47gi+fZ7KJmfc/5rNyy1CYw/cu4kZ0KPIYbo9UUgSdW0bKQJpt+WihEor6s4Lyp7+xc3a+3HeyXmAEbbnPg=="],
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-rRjDMZs9pIRKGxgijwezplKc1RnJsqUokrA9h88bbTkqQ+7ePj0ZN4ZnZDy8Vu0tXs7KRlI2tQLaK4mx9QlxHg=="],
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.8", "", { "os": "linux", "cpu": "arm" }, "sha512-jGcCd+8ra6Q61xKqZeiItujTpp9a9eRLcQ0jW6qYNku+WpupqOPFPY0SrsuSnXFviJwkpKYT9p7QrB4lsf3LNQ=="],
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-od6I2Y7kU7i1SwZYG2EKW8rWz6JiedtPpko4WEe1DDsiikrfaotVBCRaUTM5/yeZKaZ92EatoAS+5xG+6uJlYA=="],
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-yYkPbJDJiWj6N0gASA3CAvRypZmVpJnxU0DQg3aBhneLDQde9TPLKADsQkobNoJUtTT/lj46aWpzT48PDb3Qcg=="],
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.8", "", { "os": "linux", "cpu": "none" }, "sha512-PB00MSKAp4VwK/xwe6duKxRKmH8UH4GIl1pqHSbxng0jnU9Dr7FwaDypDiqwNFZ774N+8G7mJLGuLtg9NTcQsg=="],
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.8", "", { "os": "linux", "cpu": "x64" }, "sha512-TWM2XWJoitLiIPCvgJh7SriC+L/T9qkYCVzC66AidsZy0QP1hkKzBzVwshCdcA3q6fIn3yE0ISbq4lMJSy8jFw=="],
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.8", "", { "os": "linux", "cpu": "x64" }, "sha512-hb20MxKXXb5IB7AAwN8UHz9WRsa2HmdZfjsDCzjElwJoeV1aotVEwFU4FrFQcYQVzsJQLeaCc/2Qdt/0Q72mMg=="],
"@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-WwPN08IXE4SkL+FhJyPz/iFnycMAUkbphFIT4cmKLlvbSU0Zfn1R7BGJ3Hqky1S89QUYc0Q4IOScXb/42Re9wQ=="],
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.8", "", { "os": "win32", "cpu": "x64" }, "sha512-XkrVqKb+pxyba7kjy2LJvABFVBTE0DNpEl7MrG4OYUmaWarrXH+t54z/Czj2YxCKtizYTV4mg6phNm3x24qjhQ=="],
"@nivo/calendar": ["@nivo/calendar@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/legends": "0.99.0", "@nivo/text": "0.99.0", "@nivo/theming": "0.99.0", "@nivo/tooltip": "0.99.0", "@types/d3-scale": "^4.0.8", "@types/d3-time": "^1.0.10", "@types/d3-time-format": "^3.0.0", "d3-scale": "^4.0.2", "d3-time": "^1.0.10", "d3-time-format": "^3.0.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-8Y6rIjAMt4hMWpkIUmb7skr8boAdoE7Bb4liAFSkkyRgsK4iFnFbaf1x5wLk7YmyY4v8LZ4PjNyGOYPBJU2wtw=="],
"@nivo/colors": ["@nivo/colors@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@types/d3-color": "^3.0.0", "@types/d3-scale": "^4.0.8", "@types/d3-scale-chromatic": "^3.0.0", "d3-color": "^3.1.0", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.0.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w=="],

View File

@@ -0,0 +1,7 @@
CREATE TABLE `live_cards` (
`user_id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `live_cards_token_unique` ON `live_cards` (`token`);

View File

@@ -0,0 +1,17 @@
CREATE TABLE `live_discord_cards` (
`user_id` text PRIMARY KEY NOT NULL,
`channel_id` text NOT NULL,
`message_id` text,
`message_url` text,
`nonce` text NOT NULL,
`theme` text NOT NULL,
`status` text NOT NULL,
`image_hash` text,
`dirty` integer DEFAULT true NOT NULL,
`error` text,
`retry_at` integer,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
DROP TABLE `live_cards`;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -64,6 +64,20 @@
"when": 1788539875559,
"tag": "0008_reminder_cooldown",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1788596417948,
"tag": "0009_purple_wasp",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1788597003524,
"tag": "0010_tidy_thena",
"breakpoints": true
}
]
}

View File

@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "NODE_ENV=development bun --env-file=.env --env-file=.env.development scripts/migrate.ts && NODE_ENV=development bun --env-file=.env --env-file=.env.development --hot src/index.ts",
"dev": "NODE_ENV=development bun --env-file=.env --env-file=.env.development scripts/migrate.ts && NODE_ENV=development bun --env-file=.env --env-file=.env.development --watch src/index.ts",
"start": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts && NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/start.ts",
"build": "bun run build.ts",
"typecheck": "tsc --noEmit",
@@ -19,6 +19,7 @@
"monitor": "bun --env-file=.env --env-file=.env.production scripts/monitor.ts"
},
"dependencies": {
"@napi-rs/canvas": "^1.0.8",
"@nivo/calendar": "^0.99.0",
"bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1",

View File

@@ -10,7 +10,7 @@ const probe = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Respo
const port = probe.port!; probe.stop(true);
const origin = `http://127.0.0.1:${port}`;
const env = { ...process.env, NODE_ENV: 'production', PORT: String(port), APP_ORIGIN: origin, DATABASE_PATH: databasePath,
DISCORD_CLIENT_ID: '', DISCORD_CLIENT_SECRET: '', AUTH_COOKIE_SECRET: 'smoke-test-only-secret-at-least-32-characters' };
DISCORD_CLIENT_ID: '', DISCORD_CLIENT_SECRET: '', DISCORD_BOT_TOKEN: '', DISCORD_SHARING_CHANNEL_ID: '', AUTH_COOKIE_SECRET: 'smoke-test-only-secret-at-least-32-characters' };
const token = 's'.repeat(43);
let processHandle: ReturnType<typeof Bun.spawn> | undefined;
async function boot() {
@@ -56,6 +56,10 @@ try {
await request(`/habits/${t.id}`, 'PATCH', { archived: true });
await request(`/habits/${t.id}`, 'PATCH', { archived: false });
check((await request(`/habits/${t.id}/days/${date}`)).complete, 'Archive and restore lost task completion');
const livePreview = await fetch(`${origin}/api/sharing/live/preview?theme=light`, { headers: { Cookie: `minabot_session=${token}` } });
check(livePreview.ok, 'Production live PNG rendering failed');
const png = Buffer.from(await livePreview.arrayBuffer());
check(png.readUInt32BE(16) === 1920 && png.readUInt32BE(20) === 1080, 'Live image is not 16:9');
await stop(); await boot();
check((await request(`/habits/${h.id}/days/${date}`)).value === 7, 'Count did not survive restart');
check((await request(`/charts/${c.id}/days/${date}`)).ratio === 0.5, 'Tasks or membership did not survive restart');

View File

@@ -14,7 +14,7 @@ let sharedImage: Blob | null = null;
let reminderMessage: string | null = null;
const previewDiscord: DiscordFetch = async (url, init) => {
if (url.endsWith("/users/@me/channels")) return Response.json({ id: "523456789012345678" });
if (init?.method === "POST") {
if (init?.method === "POST" || init?.method === "PATCH") {
if (init.body instanceof FormData) sharedImage = init.body.get("files[0]") as Blob;
else reminderMessage = JSON.parse(String(init.body)).content;
return Response.json({ id: "423456789012345678" });

View File

@@ -1,3 +1,4 @@
import { createLiveDiscord } from "./sharing/live-discord";
import { createReminderRoutes } from "./reminders/routes";
import { errorDetails } from "./ops/monitor";
import { createAccountRoutes } from "./account/routes";
@@ -19,12 +20,19 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi
await next();
});
const auth = createAuth(db, config, request, now);
const liveSharing = createLiveDiscord(db, auth, sharingConfig, now ?? Date.now, discordRequest);
app.use("*", async (c, next) => {
await next();
if (!["GET", "HEAD", "OPTIONS"].includes(c.req.method) && /^\/api\/(habits|me|account)(\/|$)/.test(c.req.path) && c.res.status < 400 && c.get("user"))
liveSharing.changed(c.get("user").id);
});
app.get("/api/health", c => {
c.header("Cache-Control", "no-store");
db.get(sql`SELECT 1`);
if (operations.healthy && !operations.healthy()) return c.json({ status: "degraded" }, 503);
return c.json({ status: "ok" });
});
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));
app.route("/api/account", createAccountRoutes(db, auth, now ?? Date.now));
@@ -41,5 +49,5 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi
(operations.log ?? (event => console.error(JSON.stringify(event))))(event);
return c.json({ error: "Internal server error", requestId }, 500);
});
return app;
return Object.assign(app, { stopLiveSharing: liveSharing.stop, flushLiveSharing: liveSharing.flush });
}

View File

@@ -0,0 +1,65 @@
import { useEffect, useState } from "react";
import { Download, Send, Pause } from "lucide-react";
import { useTheme, ThemeControl } from "./ThemeProvider";
import { Button } from "./design-system/primitives";
import { habitRequest } from "../lib/dashboard";
import type { LiveDiscordState } from "../sharing/live-discord";
import type { DiscordConnection } from "../sharing/contracts";
import "../../styles/live-discord.css";
export function LiveDiscordShare({ revision }: { revision: number }) {
const { theme } = useTheme();
const [state, setState] = useState<LiveDiscordState | null>(null);
const [connection, setConnection] = useState<DiscordConnection | null>(null);
const [preview, setPreview] = useState<{ url: string; theme: string; revision: number } | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [previewError, setPreviewError] = useState('');
const [attempt, setAttempt] = useState(0);
useEffect(() => {
const controller = new AbortController();
setError('');
Promise.all([habitRequest<LiveDiscordState>('/sharing/live', { signal: controller.signal }), habitRequest<DiscordConnection>('/sharing/discord', { signal: controller.signal })])
.then(([next, destination]) => { if (!controller.signal.aborted) { setState(next); setConnection(destination); } })
.catch(e => { if (!controller.signal.aborted) setError(e.message); });
return () => controller.abort();
}, [attempt]);
useEffect(() => {
const controller = new AbortController(); let url: string | undefined;
setPreviewError('');
fetch(`/api/sharing/live/preview?theme=${theme}`, { signal: controller.signal, cache: 'no-store' })
.then(async response => { if (!response.ok) throw new Error('Could not render your card.'); return response.blob(); })
.then(blob => { if (!controller.signal.aborted) { url = URL.createObjectURL(blob); setPreview({ url, theme, revision }); } })
.catch(e => { if (!controller.signal.aborted) setPreviewError(e.message); });
return () => { controller.abort(); if (url) URL.revokeObjectURL(url); };
}, [theme, revision, attempt]);
async function change(method: 'POST' | 'DELETE') {
setBusy(true); setError('');
try { setState(await habitRequest<LiveDiscordState>('/sharing/live', { method, ...(method === 'POST' ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ theme }) } : {}) })); }
catch (e) { setError(e instanceof Error ? e.message : 'Could not update sharing.'); }
finally { setBusy(false); }
}
const ready = preview?.theme === theme && preview.revision === revision ? preview : null;
const active = state?.status === 'active' || state?.status === 'sending';
return <section className="ds-live-discord" aria-label="Live Discord image">
<div className="ds-live-discord-heading"><div><h3>All your habits. One shared image.</h3><p>1920 × 1080 · 16:9 · Last 30 days</p></div><ThemeControl disabled={busy} /></div>
<div className="ds-live-discord-preview" aria-busy={!ready && !previewError}>
{previewError ? <p role="alert">{previewError}</p> : ready ? <img src={ready.url} alt="16:9 Discord card containing all active habit names and their daily progress for the last 30 days." /> : <p role="status">Creating your image</p>}
</div>
<p className="ds-live-discord-description">The bot posts this image in {connection?.connected ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">{connection.name}</a> : 'the configured Discord channel'}, then replaces the attachment on that same message after changes settle for two seconds. Updates continue with this page closed.</p>
<p className="ds-live-discord-privacy">Shares your display name, avatar, all active habit names, and progressincluding new habits. Task details stay private. Stopping updates leaves the last image in Discord.</p>
<div className="ds-live-discord-footer">
<div aria-live="polite">{state?.messageUrl && <a href={state.messageUrl} target="_blank" rel="noreferrer">View message in Discord </a>}
<p>{state?.error || (active ? 'Live sharing is on.' : state?.status === 'paused' ? 'Updates stopped.' : 'Preview your image before sharing.')}</p>
{error && <p role="alert">{error}</p>}
{connection && !connection.connected && <p>{connection.message}</p>}
</div>
<div className="ds-share-actions">
<Button variant="text" disabled={busy} onClick={() => setAttempt(n => n + 1)}>Refresh preview</Button>
{ready && <a className="ds-button ds-button--secondary" download="minabot-live-progress.png" href={ready.url}><Download size={17} />Download PNG</a>}
{active && <Button variant="secondary" disabled={busy} onClick={() => void change('DELETE')}><Pause size={17} />Stop updates</Button>}
<Button disabled={busy || !ready || !state || !connection?.connected || state.status === 'uncertain' || (active && state.theme === theme)} onClick={() => void change('POST')}><Send size={17} />{busy ? 'Saving…' : active ? 'Apply theme' : state?.messageUrl ? 'Resume updates' : 'Share live image'}</Button>
</div>
</div>
</section>;
}

View File

@@ -1,3 +1,4 @@
import { LiveDiscordShare } from "./LiveDiscordShare";
import { ThemeControl, useTheme } from "./ThemeProvider";
import { Modal } from "./design-system/Modal";
import { useEffect, useRef, useState } from "react";
@@ -12,6 +13,7 @@ import type { Delivery, DiscordConnection, ShareData } from "../sharing/contract
export function ShareProgress({ user, today, revision, onClose }: { user: PublicUser; today: TodayResponse; revision: number; onClose: () => void }) {
const { theme } = useTheme();
const [mode, setMode] = useState<"snapshot" | "live">("live");
const [ids, setIds] = useState(() => today.habits.slice(0, 1).map(h => h.habitId));
const [thumbnail, setThumbnail] = useState(true);
const [range, setRange] = useState("30");
@@ -44,6 +46,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
}, [connectionAttempt]);
useEffect(() => {
const controller = new AbortController();
if (mode === "live") return () => controller.abort();
let objectUrl: string | undefined;
setError(""); setSendError("");
const timer = setTimeout(async () => {
@@ -62,7 +65,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
} catch (e) { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : "Could not create your card."); }
}, 200);
return () => { clearTimeout(timer); controller.abort(); if (objectUrl) URL.revokeObjectURL(objectUrl); };
}, [key, renderAttempt]);
}, [key, renderAttempt, mode]);
async function send() {
if (!ready || !connection?.connected || lock.current || sent) return;
@@ -74,6 +77,12 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
}
return (
<Modal id="share-progress" eyebrow="SHARE A LITTLE PROGRESS" title="Made for your people." description="Choose what to include. See exactly what youll share." onClose={onClose} closeLabel="Close sharing" busy={busy} returnFocus={() => document.getElementById("open-sharing")}>
<div className="ds-sharing-mode" role="group" aria-label="Sharing format">
<Button variant={mode === "live" ? "primary" : "secondary"} disabled={busy} aria-pressed={mode === "live"} onClick={() => setMode("live")}>Live image · 16:9</Button>
<Button variant={mode === "snapshot" ? "primary" : "secondary"} disabled={busy} aria-pressed={mode === "snapshot"} onClick={() => setMode("snapshot")}>Snapshot</Button>
</div>
{mode === "live" ? <LiveDiscordShare revision={revision} /> : <>
<div className="ds-share-layout">
<section className="ds-share-preview-column" aria-label="Card preview">
<div className="ds-share-preview-heading"><span><ImageIcon size={16} aria-hidden="true" /> Your card</span>
@@ -116,6 +125,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
<Button disabled={!ready || !connection?.connected || busy || !!sent} onClick={() => void send()}>{sent?.status === "sent" ? <Check size={17} aria-hidden="true" /> : <Send size={17} aria-hidden="true" />}{sending ? "Sending…" : sent?.status === "sent" ? "Shared" : sent ? "Check Discord" : "Send to Discord"}</Button>
</div>
</footer>
</>}
</Modal>
);
}

View File

@@ -84,3 +84,18 @@ export const reminderDeliveries = sqliteTable('reminder_deliveries', {
export const reminderWorkerState = sqliteTable('reminder_worker_state', {
id: text('id').primaryKey(), blockedUntil: integer('blocked_until').notNull(),
});
export const liveDiscordCards = sqliteTable('live_discord_cards', {
userId: text('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
channelId: text('channel_id').notNull(),
messageId: text('message_id'),
messageUrl: text('message_url'),
nonce: text('nonce').notNull(),
theme: text('theme').$type<import('../lib/theme').Theme>().notNull(),
status: text('status').$type<'active' | 'paused' | 'sending' | 'uncertain' | 'error'>().notNull(),
imageHash: text('image_hash'),
dirty: integer('dirty', { mode: 'boolean' }).notNull().default(true),
error: text('error'),
retryAt: integer('retry_at'),
updatedAt: integer('updated_at').notNull(),
});

View File

@@ -22,5 +22,5 @@ export function fixture(path = ':memory:') {
if (response.status !== status) throw new Error(`${method} ${path}: expected ${status}, got ${response.status}: ${JSON.stringify(data)}`);
return data;
}
return { sqlite, db, app, origin, request, json, setTime: (date: string) => { time = Date.parse(date); }, close: () => sqlite.close() };
return { sqlite, db, app, origin, request, json, setTime: (date: string) => { time = Date.parse(date); }, close: () => { app.stopLiveSharing(); sqlite.close(); } };
}

View File

@@ -22,6 +22,8 @@ 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) });
if (import.meta.hot) import.meta.hot.dispose(() => app.stopLiveSharing());
const server = Bun.serve({
hostname: "127.0.0.1",
port: Number(process.env.PORT ?? 3000),

View File

@@ -0,0 +1,127 @@
import { createCanvas, GlobalFonts, loadImage } from "@napi-rs/canvas";
import { existsSync } from "node:fs";
import { eq } from "drizzle-orm";
import type { AppDatabase } from "../auth";
import { users } from "../db/schema";
import { HabitService, ApiError } from "../habits/service";
import { addDays } from "../habits/calendar";
import { themePalette, type Theme } from "../lib/theme";
import { cardDate } from "../lib/progress-card";
import { calendarTimeline } from "../components/design-system/calendar-model";
import { themedHabitShade } from "../lib/habit-palette";
const sourceFont = new URL('../assets/fonts/InstrumentSerif-Regular.ttf', import.meta.url);
const builtFont = new URL('./fonts/InstrumentSerif-Regular.ttf', import.meta.url);
GlobalFonts.registerFromPath((existsSync(sourceFont) ? sourceFont : builtFont).pathname, 'Instrument Serif');
export function liveCardData(db: AppDatabase, userId: string, now: number) {
const user = db.select().from(users).where(eq(users.id, userId)).get();
if (!user) throw new ApiError(404, "Account unavailable.");
const service = new HabitService(db, { ...user, displayName: user.globalName ?? user.username, avatarUrl: null }, now);
service.sync();
const from = addDays(service.today, -29);
const avatarUrl = /^\d{17,20}$/.test(user.discordId) && /^(?:a_)?[a-f0-9]{32}$/i.test(user.avatarHash ?? '')
? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatarHash}.png?size=128` : null;
return { name: user.globalName ?? user.username, avatarUrl, from, to: service.today, habits: service.list().map(h => {
const chart = service.calendar([h.id], from, service.today, service.settings(h.id), false);
return { name: h.name, color: chart.settings.mainColor,
completed: chart.days.filter(d => d.completed > 0).length, scheduled: chart.days.filter(d => d.due > 0).length,
days: chart.days.map(d => ({ date: d.date, color: d.color, due: d.due > 0, ratio: d.ratio })) };
}) };
}
export type LiveImageData = ReturnType<typeof liveCardData>;
export function liveCardLayout(count: number) {
const columns = count <= 8 ? 2 : Math.ceil(Math.sqrt(count / 2));
const rows = Math.max(1, Math.ceil(count / columns));
return { columns, rows, width: 1920, height: 1080, panelWidth: (1792 - (columns - 1) * 32) / columns, panelHeight: 650 / rows };
}
/** A deterministic 16:9 PNG. Every active habit receives a panel; none are paginated away. */
export async function renderLiveCard(data: LiveImageData, theme: Theme) {
const layout = liveCardLayout(data.habits.length);
const canvas = createCanvas(layout.width, layout.height);
const ctx = canvas.getContext('2d');
const colors = themePalette[theme];
let avatar: Awaited<ReturnType<typeof loadImage>> | null = null;
if (data.avatarUrl) {
try {
const response = await fetch(data.avatarUrl, { redirect: 'error', signal: AbortSignal.timeout(3000) });
if (response.ok) avatar = await loadImage(Buffer.from(await response.arrayBuffer()));
} catch { /* Match the snapshot card's neutral avatar fallback. */ }
}
const rect = (x: number, y: number, w: number, h: number, color: string, radius = 0) => {
ctx.fillStyle = color; ctx.beginPath(); ctx.roundRect(x, y, Math.max(0, w), Math.max(0, h), radius); ctx.fill();
};
const text = (value: string, x: number, y: number, size: number, width: number, color: string = colors.ink, serif = false, weight = '400') => {
ctx.font = `${weight} ${size}px ${serif ? '"Instrument Serif"' : 'sans-serif'}`; ctx.fillStyle = color;
const chars = Array.from(value); let fitted = value;
while (chars.length && ctx.measureText(fitted).width > width) { chars.pop(); fitted = chars.join('') + '…'; }
ctx.fillText(fitted, x, y);
};
rect(0, 0, 1920, 1080, colors.paper);
rect(0, 0, 1920, 8, data.habits[0]?.color ?? '#285d49');
// The same profile / wordmark hierarchy as the snapshot card.
ctx.save(); ctx.beginPath(); ctx.arc(106, 94, 42, 0, Math.PI * 2); ctx.clip();
rect(64, 52, 84, 84, colors.avatar);
if (avatar) ctx.drawImage(avatar, 64, 52, 84, 84);
else {
ctx.fillStyle = colors.muted; ctx.beginPath(); ctx.arc(106, 85, 14, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(106, 130, 30, 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
text(data.name, 172, 109, 44, 1140, colors.ink, false, '600');
ctx.textAlign = 'right'; text('Mina Habits.', 1856, 112, 64, 470, colors.ink, true); ctx.textAlign = 'left';
text('A little, every day.', 64, 228, 96, 1792, colors.ink, true);
text(`${cardDate(data.from)} ${cardDate(data.to)}`, 64, 289, 36, 1600, colors.muted);
data.habits.forEach((habit, index) => {
const x = 64 + index % layout.columns * (layout.panelWidth + 32);
const y = 340 + Math.floor(index / layout.columns) * layout.panelHeight;
const w = layout.panelWidth, h = layout.panelHeight;
const compact = h < 220;
const fontSize = Math.min(compact ? 26 : 38, h * (compact ? .20 : .105), w * .05);
const labelSize = Math.min(28, h * .067, w * .04);
rect(x, y, w, 1, colors.rule);
text(habit.name, x, y + fontSize + h * .035, fontSize, w, colors.ink, false, '600');
if (compact) {
text(`${habit.completed} / ${habit.scheduled}`, x, y + h * .56, Math.min(40, h * .30), w * .46, colors.ink, false, '600');
text('check-ins complete', x, y + h * .75, Math.min(20, h * .14), w * .46, colors.muted);
} else {
text(`${habit.completed}`, x, y + h * .43, Math.min(104, h * .25), w * .43, colors.ink, false, '600');
text('check-ins complete', x, y + h * .54, labelSize, w * .46, colors.ink, false, '500');
text(`of ${habit.scheduled} scheduled`, x, y + h * .64, labelSize, w * .46, colors.muted);
text('over 30 days', x, y + h * .74, labelSize, w * .46, colors.muted);
}
const chart = calendarTimeline(habit.days.map(day => ({ date: day.date, value: day.ratio ?? 0, target: 1, state: day.due ? 'due' : 'not-due', color: day.color })));
const chartWidth = w * .44;
const step = Math.min(44, h * .61 / 7, chartWidth / Math.max(1, chart.weeks));
const cell = step * .86;
const chartX = x + w * .56 + (chartWidth - chart.weeks * step) / 2;
const chartY = y + h * (compact ? .30 : .19);
for (const row of [1, 3, 5]) {
ctx.textAlign = 'right';
text(['S', 'M', 'T', 'W', 'T', 'F', 'S'][row]!, chartX - step * .38, chartY + row * step + cell * .8, Math.min(26, step * .7), step, colors.muted);
ctx.textAlign = 'left';
}
chart.entries.forEach(({ day, column, row }) => {
const dx = chartX + (column - 1) * step, dy = chartY + (row - 1) * step;
if (day.state === 'due') rect(dx, dy, cell, cell, day.value ? themedHabitShade(day.color!, habit.color, theme) : colors.empty, Math.min(3, cell / 6));
else { ctx.fillStyle = colors.dot; ctx.beginPath(); ctx.arc(dx + cell / 2, dy + cell / 2, Math.max(.5, cell * .07), 0, Math.PI * 2); ctx.fill(); }
});
if (compact) return;
const legendY = y + h * .92;
const legendSize = Math.min(23, h * .052, w * .03);
text('0', x, legendY, legendSize, w * .05, colors.muted);
const shadeColors = [colors.empty, ...[...new Set(habit.days.filter(d => d.due && d.ratio && d.ratio < 1).map(d => d.color))].slice(0, 3).map(color => themedHabitShade(color, habit.color, theme)), themedHabitShade(habit.color, habit.color, theme)];
const swatch = legendSize * .72;
shadeColors.forEach((color, i) => rect(x + legendSize * 1.1 + i * legendSize, legendY - swatch, swatch, swatch, color, Math.min(2, swatch / 5)));
text('Complete', x + legendSize * (shadeColors.length + 1.4), legendY, legendSize, w * .26, colors.muted);
ctx.fillStyle = colors.dot; ctx.beginPath(); ctx.arc(x + w * .70, legendY - legendSize * .3, Math.max(.5, legendSize * .1), 0, Math.PI * 2); ctx.fill();
ctx.textAlign = 'right'; text('Not scheduled', x + w, legendY, legendSize, w * .28, colors.muted); ctx.textAlign = 'left';
});
if (!data.habits.length) text('No active habits yet.', 64, 440, 36, 1400, colors.muted);
rect(64, 1010, 1792, 1, colors.rule);
text('Totals cover this entire period', 64, 1050, 28, 1200, colors.muted);
ctx.textAlign = 'right'; text(`${data.habits.length} active habits`, 1856, 1050, 28, 536, colors.muted);
return canvas.encode('png');
}

View File

@@ -0,0 +1,52 @@
import { test, expect } from 'bun:test';
import { mkdtempSync, writeFileSync, appendFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
test('development process restart preserves the live message and subsequent debounced updates', async () => {
const directory = mkdtempSync(join(tmpdir(), 'minabot-live-watch-'));
const entry = join(directory, 'server.ts');
const log = join(directory, 'deliveries.log');
const probe = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response() });
const port = probe.port!; probe.stop(true);
const origin = `http://127.0.0.1:${port}`;
const channelId = '223456789012345678', messageId = '423456789012345678';
writeFileSync(entry, `
import { fixture } from ${JSON.stringify(resolve('src/habits/test-fixture.ts'))};
import { createApi } from ${JSON.stringify(resolve('src/api.ts'))};
import { appendFileSync } from 'node:fs';
const f = fixture(${JSON.stringify(join(directory, 'test.sqlite'))}); f.app.stopLiveSharing();
const app = createApi(f.db, {origin:${JSON.stringify(origin)},clientId:'',clientSecret:'',cookieSecret:'test-secret-at-least-32-characters'}, undefined, () => Date.parse('2026-09-04T12:00:00Z'), async (url, init) => {
if (init?.method) appendFileSync(${JSON.stringify(log)}, JSON.stringify({method:init.method,url})+'\\n');
return Response.json(init?.method ? {id:'${messageId}'} : {id:'${channelId}',guild_id:'323456789012345678',type:0});
}, {token:'test-only-token',channelId:'${channelId}'});
Bun.serve({hostname:'127.0.0.1',port:${port},routes:{'/api/*':req=>app.fetch(req)}});
globalThis.watchBootCount = (globalThis.watchBootCount ?? 0) + 1;
appendFileSync(${JSON.stringify(log)},JSON.stringify({event:'boot',generation:globalThis.watchBootCount})+'\\n');
`);
const server = Bun.spawn([process.execPath, '--watch', entry], { cwd: process.cwd(), env: { ...process.env, NODE_ENV: 'development', DISCORD_BOT_TOKEN: '' }, stdout: 'ignore', stderr: 'ignore' });
const events = () => existsSync(log) ? readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line)) : [];
async function until(check: () => boolean) {
const deadline = Date.now() + 7000;
while (!check()) { if (Date.now() > deadline) throw new Error('Live worker did not make progress'); await Bun.sleep(50); }
}
async function call(path: string, method: string, body: unknown) {
const response = await fetch(`${origin}/api${path}`, { method, headers: { Origin: origin, Cookie: `minabot_session=${'a'.repeat(43)}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
expect(response.ok).toBe(true); return response.json();
}
try {
await until(() => events().some(e => e.event === 'boot'));
const habit = await call('/habits', 'POST', {name:'Read',method:'count',target:10});
await call('/sharing/live', 'POST', {theme:'light'});
appendFileSync(entry, '\n// Simulate a source change.\n');
await until(() => events().filter(e => e.event === 'boot').length === 2);
const boots = events().filter(e => e.event === 'boot'); expect(boots.map(e => e.generation)).toEqual([1, 1]);
await call(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', {count:8});
await until(() => events().some(e => e.method === 'PATCH'));
const deliveries = events().filter(e => e.method);
expect(deliveries.map(e => e.method)).toEqual(['POST', 'PATCH']);
expect(deliveries[1].url).toEndWith(`/messages/${messageId}`);
} finally {
server.kill(); await server.exited; rmSync(directory, {recursive:true,force:true});
}
}, 20000);

View File

@@ -0,0 +1,153 @@
import { afterEach, test, expect } from 'bun:test';
import { fixture } from '../habits/test-fixture';
import { createApi } from '../api';
import { liveDiscordCards } from '../db/schema';
import { eq } from 'drizzle-orm';
import { liveCardData, liveCardLayout } from './live-card-image';
import type { DiscordFetch } from './routes';
const cleanups: (() => void)[] = [];
afterEach(() => { for (const cleanup of cleanups.splice(0)) cleanup(); });
const channelId = '223456789012345678';
const messageId = '423456789012345678';
const config = { token: 'test-bot-secret', channelId };
function setup(custom?: DiscordFetch) {
const f = fixture();
const writes: { url: string; method: string; body: FormData }[] = [];
const request: DiscordFetch = async (url, init) => {
if (init?.method === 'POST' || init?.method === 'PATCH') writes.push({ url, method: init.method, body: init.body as FormData });
return custom ? custom(url, init) : Response.json(init?.method ? { id: messageId } : { id: channelId, guild_id: '323456789012345678', type: 0, name: 'test' });
};
const app = createApi(f.db, { origin: f.origin, clientId: '', clientSecret: '', cookieSecret: 'test-secret-with-at-least-32-characters' }, undefined, () => Date.parse('2026-09-04T12:00:00Z'), request, config);
cleanups.push(() => { app.stopLiveSharing(); f.close(); });
const call = (path: string, method = 'GET', body?: unknown, user = 'a', origin = f.origin) => app.request(`${f.origin}/api${path}`, { method, headers: { Origin: origin, Cookie: `minabot_session=${user.repeat(43)}`, 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) });
const start = () => call('/sharing/live', 'POST', { theme: 'light' });
return { f, app, call, start, writes, request };
}
test('PNG is 1920x1080 and includes more than six habits, without private tasks', async () => {
const { f, call } = setup();
for (let i = 0; i < 14; i++) await f.json('/habits', 'POST', { name: `Habit ${i}`, method: 'manual' }, 201);
await f.json('/habits', 'POST', { name: 'Tasks', method: 'tasks', tasks: [{ name: 'SECRET TASK' }] }, 201);
const data = liveCardData(f.db, 'alice', Date.parse('2026-09-04T12:00:00Z'));
expect(data.habits).toHaveLength(15); expect(JSON.stringify(data)).not.toContain('SECRET TASK');
const response = await call('/sharing/live/preview?theme=dark');
expect(response.status).toBe(200); expect(response.headers.get('cache-control')).toBe('no-store');
const png = Buffer.from(await response.arrayBuffer());
expect(png.readUInt32BE(16)).toBe(1920); expect(png.readUInt32BE(20)).toBe(1080); expect(png.byteLength).toBeLessThan(4 * 1024 * 1024);
for (const count of [1, 6, 15, 40, 100]) { const layout = liveCardLayout(count); expect(layout.rows * layout.columns).toBeGreaterThanOrEqual(count); expect(layout.panelHeight * layout.rows).toBeLessThanOrEqual(650); }
});
test('authenticated same-origin controls; no public live link or image', async () => {
const { call, app } = setup();
expect((await call('/sharing/live', 'POST', { theme: 'light' }, 'x')).status).toBe(401);
expect((await call('/sharing/live', 'POST', { theme: 'light' }, 'a', 'https://evil.example')).status).toBe(403);
expect((await call('/sharing/live/preview', 'GET', undefined, 'x')).status).toBe(401);
expect((await app.request('/api/live-card/view/anything')).status).toBe(404);
});
test('posts once, debounces mutations, skips unchanged content, then replaces attachment on same message', async () => {
const { f, app, call, start, writes } = setup();
const habit = await f.json('/habits', 'POST', { name: 'Read', method: 'count', target: 10 }, 201);
const first = await start(); expect(first.status).toBe(200); expect((await first.json()).status).toBe('active');
expect(writes).toHaveLength(1); expect(writes[0]!.method).toBe('POST');
await start(); expect(writes).toHaveLength(1);
for (const count of [2, 5, 10]) await call(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { count });
expect(writes).toHaveLength(1);
await app.flushLiveSharing('alice'); expect(writes).toHaveLength(2);
expect(writes[1]!.url).toBe(`https://discord.com/api/v10/channels/${channelId}/messages/${messageId}`);
expect(writes[1]!.method).toBe('PATCH');
const payload = JSON.parse(String(writes[1]!.body.get('payload_json')));
expect(payload.attachments).toHaveLength(1); expect(payload.attachments[0].id).toBe(0); expect(payload.allowed_mentions).toEqual({ parse: [] });
expect(writes[1]!.body.get('files[0]')).toBeInstanceOf(File);
await call(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { count: 10 });
await app.flushLiveSharing('alice'); expect(writes).toHaveLength(2);
await call('/sharing/live', 'DELETE');
await call(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { count: 0 });
await app.flushLiveSharing('alice'); expect(writes).toHaveLength(2);
await start(); expect(writes).toHaveLength(3); expect(writes[2]!.method).toBe('PATCH');
expect((await (await call('/sharing/live', 'GET', undefined, 'b')).json()).status).toBe('off');
});
test('unconfirmed initial delivery cannot be duplicated by retry, stop, or restart', async () => {
const { f, start, call, writes } = setup(async (_url, init) => {
if (init?.method === 'POST') throw new Error('network interrupted');
return Response.json({ id: channelId, guild_id: '323456789012345678', type: 0 });
});
expect((await (await start()).json()).status).toBe('uncertain');
await call('/sharing/live', 'DELETE');
expect((await start()).status).toBe(409); expect(writes).toHaveLength(1);
expect(f.db.select().from(liveDiscordCards).get()!.status).toBe('uncertain');
});
test('known message is reused after restart and rate limits defer edits', async () => {
let failure = 0;
const { f, app, start, call, writes, request } = setup(async (_url, init) => init?.method === 'PATCH' && failure ? Response.json({ retry_after: 3 }, { status: failure }) : Response.json(init?.method ? { id: messageId } : { id: channelId, guild_id: '323456789012345678', type: 0 }));
await start(); app.stopLiveSharing();
const resumed = createApi(f.db, { origin: f.origin, clientId: '', clientSecret: '', cookieSecret: 'test-secret-with-at-least-32-characters' }, undefined, () => Date.parse('2026-09-04T12:00:00Z'), request, config);
cleanups.unshift(() => resumed.stopLiveSharing());
await resumed.flushLiveSharing('alice'); expect(writes).toHaveLength(1);
f.db.update(liveDiscordCards).set({ theme: 'dark' }).where(eq(liveDiscordCards.userId, 'alice')).run();
failure = 429; await resumed.flushLiveSharing('alice'); expect(writes).toHaveLength(2);
expect(f.db.select().from(liveDiscordCards).get()!.retryAt).not.toBeNull();
await resumed.flushLiveSharing('alice'); expect(writes).toHaveLength(2);
expect(writes.filter(w => w.method === 'POST')).toHaveLength(1);
});
test('deleted Discord message is replaced once and later edits use the new message', async () => {
const replacementId = '523456789012345678';
let posts = 0;
const { f, app, start, writes } = setup(async (url, init) => {
if (init?.method === 'PATCH' && url.endsWith(`/${messageId}`)) return Response.json({ code: 10008 }, { status: 404 });
if (init?.method === 'POST') return Response.json({ id: ++posts === 1 ? messageId : replacementId });
return Response.json({ id: channelId, guild_id: '323456789012345678', type: 0 });
});
await start();
const firstNonce = JSON.parse(String(writes[0]!.body.get('payload_json'))).nonce;
f.db.update(liveDiscordCards).set({ theme: 'dark' }).run();
await Promise.all([app.flushLiveSharing('alice'), app.flushLiveSharing('alice')]);
const saved = f.db.select().from(liveDiscordCards).get()!;
expect(saved.status).toBe('active'); expect(saved.messageId).toBe(replacementId);
expect(saved.messageUrl).toEndWith(`/${replacementId}`);
expect(writes.map(w => w.method)).toEqual(['POST', 'PATCH', 'POST']);
const payload = JSON.parse(String(writes[2]!.body.get('payload_json')));
expect(payload.nonce).not.toBe(firstNonce); expect(payload.enforce_nonce).toBe(true);
expect(payload.content).toContain('updates live'); expect(writes[2]!.body.get('files[0]')).toBeInstanceOf(File);
await app.flushLiveSharing('alice'); expect(writes).toHaveLength(3);
f.db.update(liveDiscordCards).set({ theme: 'light' }).run();
await app.flushLiveSharing('alice'); expect(writes[3]!.url).toEndWith(`/${replacementId}`);
expect(posts).toBe(2);
});
test('missing channels and forbidden messages never trigger replacement posts', async () => {
for (const [status, code] of [[404, 10003], [403, 10008], [404, undefined]]) {
const { f, app, start, writes } = setup(async (_url, init) => init?.method === 'PATCH' ? Response.json({ code }, { status }) : Response.json(init?.method ? { id: messageId } : { id: channelId, guild_id: '323456789012345678', type: 0 }));
await start(); f.db.update(liveDiscordCards).set({ theme: 'dark' }).run();
await app.flushLiveSharing('alice');
expect(f.db.select().from(liveDiscordCards).get()!.status).toBe('error');
expect(writes.filter(w => w.method === 'POST')).toHaveLength(1);
}
});
test('unconfirmed replacement is not duplicated on retry', async () => {
let posts = 0;
const { f, app, start, writes } = setup(async (_url, init) => {
if (init?.method === 'PATCH') return Response.json({ code: 10008 }, { status: 404 });
if (init?.method === 'POST') { if (++posts > 1) throw new Error('network interrupted'); return Response.json({ id: messageId }); }
return Response.json({ id: channelId, guild_id: '323456789012345678', type: 0 });
});
await start(); f.db.update(liveDiscordCards).set({ theme: 'dark' }).run();
await app.flushLiveSharing('alice');
expect(f.db.select().from(liveDiscordCards).get()!.status).toBe('uncertain');
expect((await start()).status).toBe(409); expect(posts).toBe(2);
});
test('debounced worker edits without any open dashboard', async () => {
const { f, start, call, writes } = setup();
const habit = await f.json('/habits', 'POST', { name: 'Walk', method: 'manual' }, 201);
await start();
await call(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { done: true });
expect(writes).toHaveLength(1);
await Bun.sleep(2300);
expect(writes).toHaveLength(2); expect(writes[1]!.method).toBe('PATCH');
});

190
src/sharing/live-discord.ts Normal file
View File

@@ -0,0 +1,190 @@
import { createHash, randomBytes } from "node:crypto";
import { eq } from "drizzle-orm";
import { Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import { z } from "zod";
import type { AppDatabase, AuthEnv, createAuth } from "../auth";
import { liveDiscordCards, users } from "../db/schema";
import { ApiError } from "../habits/service";
import { endOfDay, localDate } from "../habits/calendar";
import { liveCardData, renderLiveCard } from "./live-card-image";
import type { DiscordSharingConfig } from "./config";
import type { DiscordFetch } from "./routes";
const liveMessageText = "🔄 This image updates live as habit progress changes. Updates appear in this same message.";
const snowflake = /^\d{17,20}$/;
const options = z.object({ theme: z.enum(['light', 'dark']) }).strict();
export type LiveDiscordState = { status: 'off' | 'active' | 'paused' | 'sending' | 'uncertain' | 'error'; messageUrl: string | null; error: string | null; theme?: 'light' | 'dark' };
export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof createAuth>, config: DiscordSharingConfig, now: () => number, request: DiscordFetch = fetch) {
const app = new Hono<AuthEnv>();
const timers = new Map<string, ReturnType<typeof setTimeout>>();
const running = new Map<string, Promise<void>>();
let stopped = false;
// Serialize all sends in this Bun process, including different users.
let queue: Promise<void> = Promise.resolve();
let channelRetryAt = 0;
const configured = Boolean(config.token && !/\s/.test(config.token) && snowflake.test(config.channelId));
const row = (id: string) => db.select().from(liveDiscordCards).where(eq(liveDiscordCards.userId, id)).get();
const update = (id: string, values: Partial<typeof liveDiscordCards.$inferInsert>) => db.update(liveDiscordCards).set(values).where(eq(liveDiscordCards.userId, id)).run();
const allowed = (discordId: string) => !config.allowedUserIds?.length || config.allowedUserIds.includes(discordId);
function state(id: string): LiveDiscordState {
const current = row(id);
return current ? { status: current.status, messageUrl: current.messageUrl, error: current.error, theme: current.theme } : { status: 'off', messageUrl: null, error: null };
}
function schedule(id: string, delay: number) {
if (stopped) return;
clearTimeout(timers.get(id));
const timer = setTimeout(() => { timers.delete(id); void flush(id); }, Math.max(0, delay));
timer.unref(); timers.set(id, timer);
}
function changed(id: string) {
const current = row(id);
if (!current || !['active', 'sending'].includes(current.status)) return;
update(id, { dirty: true });
// Trailing debounce combines rapid check-ins into one attachment replacement.
schedule(id, Math.max(2000, (current.retryAt ?? 0) - now(), channelRetryAt - now()));
}
function rollover(id: string) {
const owner = db.select().from(users).where(eq(users.id, id)).get();
if (!owner) return;
const timestamp = now();
schedule(id, endOfDay(localDate(timestamp, owner.timezone), owner.timezone) - timestamp + 1);
}
async function deliver(id: string) {
if (stopped) return;
const current = row(id);
if (!current || !['active', 'sending'].includes(current.status)) return;
const owner = db.select().from(users).where(eq(users.id, id)).get();
if (!owner || !configured || !allowed(owner.discordId) || current.channelId !== config.channelId) {
update(id, { status: 'error', error: 'Live sharing is unavailable for this account or channel.' }); return;
}
const delay = Math.max(current.retryAt ?? 0, channelRetryAt) - now();
if (delay > 0) { schedule(id, delay); return; }
update(id, { dirty: false, retryAt: null });
const data = liveCardData(db, id, now());
// Hash the rendered content, excluding timestamps and private task details.
const hash = createHash('sha256').update(JSON.stringify({ data, theme: current.theme, renderer: 2, content: liveMessageText })).digest('hex');
if (current.messageId && current.imageHash === hash) {
update(id, { error: null }); rollover(id); return;
}
let guildId: string | undefined;
if (!current.messageId) {
try {
const channelResponse = await request(`https://discord.com/api/v10/channels/${current.channelId}`, { headers: { Authorization: `Bot ${config.token}` }, redirect: 'error', signal: AbortSignal.timeout(10000) });
const channel = channelResponse.ok ? await channelResponse.json() as { id?: string; guild_id?: string; type?: number } : null;
if (!channel || channel.id !== current.channelId || !snowflake.test(channel.guild_id ?? '') || ![0, 5, 10, 11, 12].includes(channel.type ?? -1)) {
update(id, { status: 'error', error: 'The bot cannot access a supported Discord server channel. Check its configuration.' }); return;
}
guildId = channel.guild_id;
} catch { update(id, { status: 'error', error: 'Could not reach the Discord channel. Try again.' }); return; }
}
const png = await renderLiveCard(data, current.theme);
if (png.byteLength > 4 * 1024 * 1024) { update(id, { status: 'error', error: 'The rendered image exceeds the 4 MB sharing limit.' }); return; }
// A stop or account deletion during image generation cancels the outbound edit.
if (stopped || !['active', 'sending'].includes(row(id)?.status ?? '')) return;
const body = new FormData();
body.set('payload_json', JSON.stringify({ content: liveMessageText, allowed_mentions: { parse: [] },
...(!current.messageId ? { nonce: current.nonce, enforce_nonce: true } : {}),
attachments: [{ id: 0, filename: 'minabot-live-progress.png', description: `${data.name}: all ${data.habits.length} active habits, ${data.from} to ${data.to}.` .slice(0, 1024) }] }));
body.set('files[0]', new Blob([new Uint8Array(png)], { type: 'image/png' }), 'minabot-live-progress.png');
let response: Response;
try {
response = await request(`https://discord.com/api/v10/channels/${current.channelId}/messages${current.messageId ? `/${current.messageId}` : ''}`, {
method: current.messageId ? 'PATCH' : 'POST', headers: { Authorization: `Bot ${config.token}` }, body, redirect: 'error', signal: AbortSignal.timeout(15000),
});
} catch { handleFailure(id, current.messageId, 'Discord could not confirm the update.'); return; }
if (!row(id)) return;
if (!response.ok) {
if (response.status === 429) {
const payload = await response.json().catch(() => null) as { retry_after?: number } | null;
const seconds = Number(payload?.retry_after ?? response.headers.get('Retry-After') ?? 5);
const wait = Math.min(86400000, Math.max(2000, Number.isFinite(seconds) ? seconds * 1000 : 5000));
channelRetryAt = now() + wait;
update(id, { dirty: true, retryAt: channelRetryAt, error: 'Discord rate limit; update queued.' }); schedule(id, wait); return;
}
if (response.status >= 500) { handleFailure(id, current.messageId, 'Discord could not confirm the update.'); return; }
if (response.status === 404 && current.messageId) {
const failure = await response.json().catch(() => null) as { code?: number } | null;
// Unknown Message is distinct from a missing channel or lost permissions.
// Only a confirmed deletion authorizes a fresh post and delivery nonce.
if (failure?.code === 10008 && !stopped && row(id)?.status === 'active') {
update(id, { messageId: null, messageUrl: null, imageHash: null, nonce: randomBytes(12).toString('hex'),
status: 'sending', dirty: true, retryAt: null, error: null });
await deliver(id);
return;
}
if (row(id)?.status === 'paused' || stopped) return;
}
update(id, { status: 'error', error: response.status === 404 ? 'The Discord message or channel is unavailable. No replacement message was posted.' : 'Discord rejected the update. Check the bot permissions and configuration.' }); return;
}
const message = await response.json().catch(() => null) as { id?: string; channel_id?: string } | null;
const messageId = current.messageId ?? (snowflake.test(message?.id ?? '') ? message!.id! : null);
if (!messageId) { update(id, { status: 'uncertain', error: 'Delivery unconfirmed. Check Discord; this card will not be posted again automatically.' }); return; }
const latest = row(id);
if (!latest) return;
update(id, { messageId, messageUrl: current.messageUrl ?? `https://discord.com/channels/${guildId}/${current.channelId}/${messageId}`,
status: latest.status === 'paused' ? 'paused' : 'active', imageHash: hash, updatedAt: now(), error: null });
if (latest.status === 'paused') return;
if (latest.dirty) schedule(id, 2000); else rollover(id);
}
function handleFailure(id: string, messageId: string | null, message: string) {
if (!row(id) || row(id)!.status === 'paused') return;
if (!messageId) update(id, { status: 'uncertain', error: `${message} Check Discord; the initial post will not be retried automatically.` });
else { update(id, { dirty: true, retryAt: now() + 10000, error: `${message} Retrying the same message.` }); schedule(id, 10000); }
}
function flush(id: string): Promise<void> {
clearTimeout(timers.get(id)); timers.delete(id);
const existing = running.get(id);
if (existing) return existing;
const work = queue.then(() => deliver(id)).catch(() => {
if (!stopped && row(id)) { update(id, { status: 'error', error: 'Could not render or update the card. Try resuming updates.' }); }
}).finally(() => running.delete(id));
queue = work; running.set(id, work); return work;
}
function stop() { stopped = true; for (const timer of timers.values()) clearTimeout(timer); timers.clear(); }
app.use('*', auth.requireAuth);
app.use('*', async (c, next) => c.req.method === 'GET' ? next() : auth.requireSameOrigin(c, next));
app.use('*', bodyLimit({ maxSize: 1024 }));
app.get('/', c => c.json(state(c.get('user').id)));
app.get('/preview', async c => {
const theme = c.req.query('theme') === 'dark' ? 'dark' : 'light';
const image = await renderLiveCard(liveCardData(db, c.get('user').id, now()), theme);
c.header('Content-Type', 'image/png'); c.header('Content-Disposition', 'inline; filename="minabot-live-progress.png"');
return c.body(new Uint8Array(image));
});
app.post('/', async c => {
if (!configured) throw new ApiError(422, 'Discord sharing has not been configured on this server.');
const user = c.get('user');
if (!allowed(user.discordId)) return c.json({ error: 'Discord sharing is limited to approved community members.' }, 403);
const input = options.safeParse(await c.req.json().catch(() => null));
if (!input.success) throw new ApiError(422, 'Choose a card theme.');
const existing = row(user.id);
if (existing?.status === 'uncertain')
throw new ApiError(409, 'Check the original delivery in Discord before starting another live card.');
if (existing?.channelId && existing.channelId !== config.channelId) throw new ApiError(409, 'The configured channel changed. Restore the original channel to resume this message.');
if (existing?.status !== 'sending') {
db.insert(liveDiscordCards).values({ userId: user.id, channelId: config.channelId, nonce: randomBytes(12).toString('hex'), theme: input.data.theme, status: 'sending', updatedAt: now() })
.onConflictDoUpdate({ target: liveDiscordCards.userId, set: { theme: input.data.theme, status: existing?.messageId ? 'active' : 'sending', dirty: true, error: null } }).run();
}
await flush(user.id);
return c.json(state(user.id));
});
app.delete('/', async c => {
const id = c.get('user').id;
if (row(id)?.status !== 'sending' && row(id)?.status !== 'uncertain') update(id, { status: 'paused' });
clearTimeout(timers.get(id)); timers.delete(id);
// Return only after any already-sent edit has finished.
await running.get(id);
if (row(id)?.status !== 'uncertain') update(id, { status: 'paused' });
clearTimeout(timers.get(id)); timers.delete(id);
return c.json(state(id));
});
// Recover only known message edits. Never risk duplicating an interrupted first post.
for (const current of db.select().from(liveDiscordCards).all()) {
if (current.status === 'sending') update(current.userId, { status: 'uncertain', error: 'Server restarted during initial delivery. Check Discord before posting again.' });
else if (current.status === 'active') schedule(current.userId, Math.max(2000, (current.retryAt ?? 0) - now()));
}
return { routes: app, changed, flush, stop };
}

14
styles/live-discord.css Normal file
View File

@@ -0,0 +1,14 @@
.ds-live-discord { display: grid; gap: 18px; }
.ds-live-discord-heading, .ds-live-discord-footer { display: flex; align-items: center; justify-content: space-between; gap: 18px; flex-wrap: wrap; }
.ds-live-discord-heading h3 { font-size: 20px; margin: 0 0 6px; }
.ds-live-discord-heading p, .ds-live-discord-footer p { margin: 4px 0; font-size: 13px; }
.ds-live-discord-preview { aspect-ratio: 16 / 9; overflow: hidden; border-radius: 12px; border: 1px solid var(--ds-border, #858a8066); display: grid; place-items: center; }
.ds-live-discord-preview img { width: 100%; height: 100%; display: block; object-fit: contain; }
.ds-live-discord-description { font-size: 14px; margin: 0; }
.ds-live-discord-privacy { font-size: 12px; opacity: .75; margin: 0; }
.ds-live-discord-footer { border-top: 1px solid #858a8033; padding-top: 18px; }
.ds-live-discord-footer .ds-share-actions { flex-wrap: wrap; }
.ds-sharing-mode { display: flex; gap: 8px; margin-bottom: 20px; }
.ds-live-discord { padding: 0 32px 24px; }
.ds-sharing-mode { padding: 0 32px; margin-top: 20px; }
@media (max-width: 600px) { .ds-live-discord { padding: 0 16px 16px; } .ds-sharing-mode { padding: 0 16px; } }