From c7aa5a1c65467c0f1778110b21ff047cc013544f Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Mon, 7 Sep 2026 11:36:47 +0200 Subject: [PATCH] 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. --- README.md | 8 + build.ts | 6 +- bun.lock | 25 + drizzle/0009_purple_wasp.sql | 7 + drizzle/0010_tidy_thena.sql | 17 + drizzle/meta/0009_snapshot.json | 1003 ++++++++++++++++++++ drizzle/meta/0010_snapshot.json | 1066 ++++++++++++++++++++++ drizzle/meta/_journal.json | 14 + package.json | 3 +- scripts/api-smoke.ts | 6 +- scripts/dashboard-preview.ts | 2 +- src/api.ts | 10 +- src/components/LiveDiscordShare.tsx | 65 ++ src/components/ShareProgress.tsx | 12 +- src/db/schema.ts | 15 + src/habits/test-fixture.ts | 2 +- src/index.ts | 2 + src/sharing/live-card-image.ts | 127 +++ src/sharing/live-discord-runtime.test.ts | 52 ++ src/sharing/live-discord.test.ts | 153 ++++ src/sharing/live-discord.ts | 190 ++++ styles/live-discord.css | 14 + 22 files changed, 2792 insertions(+), 7 deletions(-) create mode 100644 drizzle/0009_purple_wasp.sql create mode 100644 drizzle/0010_tidy_thena.sql create mode 100644 drizzle/meta/0009_snapshot.json create mode 100644 drizzle/meta/0010_snapshot.json create mode 100644 src/components/LiveDiscordShare.tsx create mode 100644 src/sharing/live-card-image.ts create mode 100644 src/sharing/live-discord-runtime.test.ts create mode 100644 src/sharing/live-discord.test.ts create mode 100644 src/sharing/live-discord.ts create mode 100644 styles/live-discord.css diff --git a/README.md b/README.md index 5abc22a..443d4b0 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/build.ts b/build.ts index 86bebc7..e55fa27 100644 --- a/build.ts +++ b/build.ts @@ -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")); diff --git a/bun.lock b/bun.lock index bcfd0c4..d7c92bb 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/drizzle/0009_purple_wasp.sql b/drizzle/0009_purple_wasp.sql new file mode 100644 index 0000000..bc442f3 --- /dev/null +++ b/drizzle/0009_purple_wasp.sql @@ -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`); \ No newline at end of file diff --git a/drizzle/0010_tidy_thena.sql b/drizzle/0010_tidy_thena.sql new file mode 100644 index 0000000..41566f9 --- /dev/null +++ b/drizzle/0010_tidy_thena.sql @@ -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`; \ No newline at end of file diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..3b7692b --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,1003 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c6bc4d47-538d-41ee-90de-66da6d01d122", + "prevId": "1861c134-87ad-4e7e-a113-0f845763483a", + "tables": { + "combined_charts": { + "name": "combined_charts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "habit_ids": { + "name": "habit_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "combined_charts_user_idx": { + "name": "combined_charts_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "combined_charts_user_id_users_id_fk": { + "name": "combined_charts_user_id_users_id_fk", + "tableFrom": "combined_charts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "discord_deliveries": { + "name": "discord_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image_hash": { + "name": "image_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message_url": { + "name": "message_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "discord_deliveries_user_id_users_id_fk": { + "name": "discord_deliveries_user_id_users_id_fk", + "tableFrom": "discord_deliveries", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habit_calendar_settings": { + "name": "habit_calendar_settings", + "columns": { + "habit_id": { + "name": "habit_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "habit_calendar_settings_habit_id_habits_id_fk": { + "name": "habit_calendar_settings_habit_id_habits_id_fk", + "tableFrom": "habit_calendar_settings", + "tableTo": "habits", + "columnsFrom": [ + "habit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habit_days": { + "name": "habit_days", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "habit_id": { + "name": "habit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_id": { + "name": "revision_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ends_at": { + "name": "ends_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count_set": { + "name": "count_set", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "done": { + "name": "done", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "habit_days_lookup_idx": { + "name": "habit_days_lookup_idx", + "columns": [ + "habit_id", + "date", + "revision_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "habit_days_habit_id_habits_id_fk": { + "name": "habit_days_habit_id_habits_id_fk", + "tableFrom": "habit_days", + "tableTo": "habits", + "columnsFrom": [ + "habit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "habit_days_revision_id_habit_revisions_id_fk": { + "name": "habit_days_revision_id_habit_revisions_id_fk", + "tableFrom": "habit_days", + "tableTo": "habit_revisions", + "columnsFrom": [ + "revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habit_revisions": { + "name": "habit_revisions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "habit_id": { + "name": "habit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "effective_date": { + "name": "effective_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "habit_revisions_date_idx": { + "name": "habit_revisions_date_idx", + "columns": [ + "habit_id", + "effective_date", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "habit_revisions_habit_id_habits_id_fk": { + "name": "habit_revisions_habit_id_habits_id_fk", + "tableFrom": "habit_revisions", + "tableTo": "habits", + "columnsFrom": [ + "habit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habits": { + "name": "habits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_date": { + "name": "created_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "materialized_through": { + "name": "materialized_through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "habits_user_idx": { + "name": "habits_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "habits_user_id_users_id_fk": { + "name": "habits_user_id_users_id_fk", + "tableFrom": "habits", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "live_cards": { + "name": "live_cards", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "live_cards_token_unique": { + "name": "live_cards_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "live_cards_user_id_users_id_fk": { + "name": "live_cards_user_id_users_id_fk", + "tableFrom": "live_cards", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "progress_events": { + "name": "progress_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "day_id": { + "name": "day_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurrence_id": { + "name": "occurrence_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "before": { + "name": "before", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "progress_events_day_idx": { + "name": "progress_events_day_idx", + "columns": [ + "day_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "progress_events_day_id_habit_days_id_fk": { + "name": "progress_events_day_id_habit_days_id_fk", + "tableFrom": "progress_events", + "tableTo": "habit_days", + "columnsFrom": [ + "day_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reminder_deliveries": { + "name": "reminder_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retry_at": { + "name": "retry_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "reminder_deliveries_user_date_idx": { + "name": "reminder_deliveries_user_date_idx", + "columns": [ + "user_id", + "date" + ], + "isUnique": true + } + }, + "foreignKeys": { + "reminder_deliveries_user_id_users_id_fk": { + "name": "reminder_deliveries_user_id_users_id_fk", + "tableFrom": "reminder_deliveries", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reminder_settings": { + "name": "reminder_settings", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'20:00'" + }, + "quiet_start": { + "name": "quiet_start", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'22:00'" + }, + "quiet_end": { + "name": "quiet_end", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'08:00'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "reminder_settings_user_id_users_id_fk": { + "name": "reminder_settings_user_id_users_id_fk", + "tableFrom": "reminder_settings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reminder_worker_state": { + "name": "reminder_worker_state", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "blocked_until": { + "name": "blocked_until", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sharing_limits": { + "name": "sharing_limits", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_occurrences": { + "name": "task_occurrences", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "day_id": { + "name": "day_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "done": { + "name": "done", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "expired_at": { + "name": "expired_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "task_occurrences_day_idx": { + "name": "task_occurrences_day_idx", + "columns": [ + "day_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "task_occurrences_day_id_habit_days_id_fk": { + "name": "task_occurrences_day_id_habit_days_id_fk", + "tableFrom": "task_occurrences", + "tableTo": "habit_days", + "columnsFrom": [ + "day_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "global_name": { + "name": "global_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_hash": { + "name": "avatar_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'UTC'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_discord_id_unique": { + "name": "users_discord_id_unique", + "columns": [ + "discord_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..d36a447 --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,1066 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "418fab6d-dda8-490f-8cb3-acccd64218bb", + "prevId": "c6bc4d47-538d-41ee-90de-66da6d01d122", + "tables": { + "combined_charts": { + "name": "combined_charts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "habit_ids": { + "name": "habit_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "combined_charts_user_idx": { + "name": "combined_charts_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "combined_charts_user_id_users_id_fk": { + "name": "combined_charts_user_id_users_id_fk", + "tableFrom": "combined_charts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "discord_deliveries": { + "name": "discord_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image_hash": { + "name": "image_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message_url": { + "name": "message_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "discord_deliveries_user_id_users_id_fk": { + "name": "discord_deliveries_user_id_users_id_fk", + "tableFrom": "discord_deliveries", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habit_calendar_settings": { + "name": "habit_calendar_settings", + "columns": { + "habit_id": { + "name": "habit_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "habit_calendar_settings_habit_id_habits_id_fk": { + "name": "habit_calendar_settings_habit_id_habits_id_fk", + "tableFrom": "habit_calendar_settings", + "tableTo": "habits", + "columnsFrom": [ + "habit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habit_days": { + "name": "habit_days", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "habit_id": { + "name": "habit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_id": { + "name": "revision_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ends_at": { + "name": "ends_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count_set": { + "name": "count_set", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "done": { + "name": "done", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "habit_days_lookup_idx": { + "name": "habit_days_lookup_idx", + "columns": [ + "habit_id", + "date", + "revision_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "habit_days_habit_id_habits_id_fk": { + "name": "habit_days_habit_id_habits_id_fk", + "tableFrom": "habit_days", + "tableTo": "habits", + "columnsFrom": [ + "habit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "habit_days_revision_id_habit_revisions_id_fk": { + "name": "habit_days_revision_id_habit_revisions_id_fk", + "tableFrom": "habit_days", + "tableTo": "habit_revisions", + "columnsFrom": [ + "revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habit_revisions": { + "name": "habit_revisions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "habit_id": { + "name": "habit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "effective_date": { + "name": "effective_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "habit_revisions_date_idx": { + "name": "habit_revisions_date_idx", + "columns": [ + "habit_id", + "effective_date", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "habit_revisions_habit_id_habits_id_fk": { + "name": "habit_revisions_habit_id_habits_id_fk", + "tableFrom": "habit_revisions", + "tableTo": "habits", + "columnsFrom": [ + "habit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "habits": { + "name": "habits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_date": { + "name": "created_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "materialized_through": { + "name": "materialized_through", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "habits_user_idx": { + "name": "habits_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "habits_user_id_users_id_fk": { + "name": "habits_user_id_users_id_fk", + "tableFrom": "habits", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "live_discord_cards": { + "name": "live_discord_cards", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message_url": { + "name": "message_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image_hash": { + "name": "image_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dirty": { + "name": "dirty", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_at": { + "name": "retry_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "live_discord_cards_user_id_users_id_fk": { + "name": "live_discord_cards_user_id_users_id_fk", + "tableFrom": "live_discord_cards", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "progress_events": { + "name": "progress_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "day_id": { + "name": "day_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurrence_id": { + "name": "occurrence_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "before": { + "name": "before", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "progress_events_day_idx": { + "name": "progress_events_day_idx", + "columns": [ + "day_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "progress_events_day_id_habit_days_id_fk": { + "name": "progress_events_day_id_habit_days_id_fk", + "tableFrom": "progress_events", + "tableTo": "habit_days", + "columnsFrom": [ + "day_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reminder_deliveries": { + "name": "reminder_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retry_at": { + "name": "retry_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "reminder_deliveries_user_date_idx": { + "name": "reminder_deliveries_user_date_idx", + "columns": [ + "user_id", + "date" + ], + "isUnique": true + } + }, + "foreignKeys": { + "reminder_deliveries_user_id_users_id_fk": { + "name": "reminder_deliveries_user_id_users_id_fk", + "tableFrom": "reminder_deliveries", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reminder_settings": { + "name": "reminder_settings", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'20:00'" + }, + "quiet_start": { + "name": "quiet_start", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'22:00'" + }, + "quiet_end": { + "name": "quiet_end", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'08:00'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "reminder_settings_user_id_users_id_fk": { + "name": "reminder_settings_user_id_users_id_fk", + "tableFrom": "reminder_settings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reminder_worker_state": { + "name": "reminder_worker_state", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "blocked_until": { + "name": "blocked_until", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sharing_limits": { + "name": "sharing_limits", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_occurrences": { + "name": "task_occurrences", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "day_id": { + "name": "day_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "done": { + "name": "done", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "expired_at": { + "name": "expired_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "task_occurrences_day_idx": { + "name": "task_occurrences_day_idx", + "columns": [ + "day_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "task_occurrences_day_id_habit_days_id_fk": { + "name": "task_occurrences_day_id_habit_days_id_fk", + "tableFrom": "task_occurrences", + "tableTo": "habit_days", + "columnsFrom": [ + "day_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "global_name": { + "name": "global_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_hash": { + "name": "avatar_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'UTC'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_discord_id_unique": { + "name": "users_discord_id_unique", + "columns": [ + "discord_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 57615e3..8a5fd32 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/package.json b/package.json index 55a5bc1..29ef2a0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/api-smoke.ts b/scripts/api-smoke.ts index d4bfc34..d2a92ec 100644 --- a/scripts/api-smoke.ts +++ b/scripts/api-smoke.ts @@ -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 | 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'); diff --git a/scripts/dashboard-preview.ts b/scripts/dashboard-preview.ts index 3a8fe4b..ba16d50 100644 --- a/scripts/dashboard-preview.ts +++ b/scripts/dashboard-preview.ts @@ -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" }); diff --git a/src/api.ts b/src/api.ts index eeac97b..aa6a06e 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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 }); } diff --git a/src/components/LiveDiscordShare.tsx b/src/components/LiveDiscordShare.tsx new file mode 100644 index 0000000..b49110e --- /dev/null +++ b/src/components/LiveDiscordShare.tsx @@ -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(null); + const [connection, setConnection] = useState(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('/sharing/live', { signal: controller.signal }), habitRequest('/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('/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
+

All your habits. One shared image.

1920 × 1080 · 16:9 · Last 30 days

+
+ {previewError ?

{previewError}

: ready ? 16:9 Discord card containing all active habit names and their daily progress for the last 30 days. :

Creating your image…

} +
+

The bot posts this image in {connection?.connected ? {connection.name} : 'the configured Discord channel'}, then replaces the attachment on that same message after changes settle for two seconds. Updates continue with this page closed.

+

Shares your display name, avatar, all active habit names, and progress—including new habits. Task details stay private. Stopping updates leaves the last image in Discord.

+
+
{state?.messageUrl && View message in Discord ↗} +

{state?.error || (active ? 'Live sharing is on.' : state?.status === 'paused' ? 'Updates stopped.' : 'Preview your image before sharing.')}

+ {error &&

{error}

} + {connection && !connection.connected &&

{connection.message}

} +
+
+ + {ready && Download PNG} + {active && } + +
+
+
; +} diff --git a/src/components/ShareProgress.tsx b/src/components/ShareProgress.tsx index 356f980..f811100 100644 --- a/src/components/ShareProgress.tsx +++ b/src/components/ShareProgress.tsx @@ -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 ( document.getElementById("open-sharing")}> +
+ + +
+ {mode === "live" ? : <> +
@@ -116,6 +125,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
+ } ); } diff --git a/src/db/schema.ts b/src/db/schema.ts index a11695a..1c1bd4d 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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().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(), +}); diff --git a/src/habits/test-fixture.ts b/src/habits/test-fixture.ts index 417e828..a7fc4ca 100644 --- a/src/habits/test-fixture.ts +++ b/src/habits/test-fixture.ts @@ -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(); } }; } diff --git a/src/index.ts b/src/index.ts index 122eebb..15a7ac9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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), diff --git a/src/sharing/live-card-image.ts b/src/sharing/live-card-image.ts new file mode 100644 index 0000000..3fc8d09 --- /dev/null +++ b/src/sharing/live-card-image.ts @@ -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; + +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> | 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'); +} diff --git a/src/sharing/live-discord-runtime.test.ts b/src/sharing/live-discord-runtime.test.ts new file mode 100644 index 0000000..4c7404c --- /dev/null +++ b/src/sharing/live-discord-runtime.test.ts @@ -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); diff --git a/src/sharing/live-discord.test.ts b/src/sharing/live-discord.test.ts new file mode 100644 index 0000000..715a120 --- /dev/null +++ b/src/sharing/live-discord.test.ts @@ -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'); +}); diff --git a/src/sharing/live-discord.ts b/src/sharing/live-discord.ts new file mode 100644 index 0000000..bba5fd2 --- /dev/null +++ b/src/sharing/live-discord.ts @@ -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, config: DiscordSharingConfig, now: () => number, request: DiscordFetch = fetch) { + const app = new Hono(); + const timers = new Map>(); + const running = new Map>(); + let stopped = false; + // Serialize all sends in this Bun process, including different users. + let queue: Promise = 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) => 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 { + 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 }; +} diff --git a/styles/live-discord.css b/styles/live-discord.css new file mode 100644 index 0000000..85a56c0 --- /dev/null +++ b/styles/live-discord.css @@ -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; } }