diff --git a/.dockerignore b/.dockerignore index 2106fbe..dea98c0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,9 +6,11 @@ node_modules .env.* !.env.example *.md -Dockerfile +LICENSE +telegram_test.html docker-compose*.yml .dockerignore .claude .vscode .idea +tmp_zips diff --git a/.env.example b/.env.example index 271e081..119b703 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,8 @@ # Database DATABASE_URL="postgresql://dragons:stash@localhost:5432/dragonsstash?schema=public" +POSTGRES_USER="dragons" +POSTGRES_PASSWORD="stash" +POSTGRES_DB="dragonsstash" # Auth.js AUTH_SECRET="generate-with-openssl-rand-base64-32" @@ -11,14 +14,23 @@ AUTH_GITHUB_SECRET="" # App NEXT_PUBLIC_APP_URL="http://localhost:3000" +APP_PORT=3000 + +# API key for external access to package/ingestion endpoints (optional) +TELEGRAM_API_KEY="" # Telegram integration (get from https://my.telegram.org/apps) TELEGRAM_API_ID="" TELEGRAM_API_HASH="" +# Telegram Bot (create via @BotFather on Telegram) +BOT_TOKEN="" +BOT_USERNAME="" # e.g. "MyDragonsStashBot" — enables deep link in Settings + # Worker (only needed when running worker container) WORKER_INTERVAL_MINUTES=60 WORKER_TEMP_DIR="/tmp/zips" TDLIB_STATE_DIR="/data/tdlib" WORKER_MAX_ZIP_SIZE_MB=4096 +MULTIPART_TIMEOUT_HOURS=0 LOG_LEVEL="info" diff --git a/Dockerfile b/Dockerfile index d206e52..08e094f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,6 +34,7 @@ COPY --from=builder /app/public ./public # Copy prisma schema + migrations for runtime migrate deploy COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/prisma.config.ts ./prisma.config.ts # Copy standalone build output COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ @@ -43,6 +44,7 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma COPY --from=builder /app/node_modules/prisma ./node_modules/prisma +COPY --from=builder /app/node_modules/dotenv ./node_modules/dotenv # Copy entrypoint script COPY --chown=nextjs:nodejs docker-entrypoint.sh ./ diff --git a/README.md b/README.md index 19c982f..909ac3d 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,14 @@ A self-hosted inventory management system for 3D printing filament, SLA resin, a - **Upload verification** — confirms files reached the destination before marking them complete - **Preview matching** — associates photo messages with their corresponding archive sets +### Telegram Bot + +- **Direct delivery** — send any indexed package to a linked Telegram account with one click from the UI +- **Account linking** — users link their Telegram account via a one-time code from Settings +- **Package search** — search or browse indexed packages directly from conversation with the bot +- **Subscription notifications** — subscribe to keyword patterns and get notified when matching packages arrive +- **Automatic forwarding** — the bot copies files from the destination channel, no manual download needed + ## Tech Stack - **Framework**: Next.js 16 (App Router) @@ -38,6 +46,7 @@ A self-hosted inventory management system for 3D printing filament, SLA resin, a - **Tables**: TanStack Table v8 with server-side pagination - **Validation**: Zod v4 + React Hook Form - **Worker**: Node.js + TDLib (via tdl) +- **Bot**: Node.js + TDLib (bot token auth) - **Archive handling**: unrar, zlib ## Quick Start @@ -110,12 +119,30 @@ Run the entire application from Docker: ```bash cp .env.example .env -# Edit .env — set TELEGRAM_API_ID, TELEGRAM_API_HASH, and a secure AUTH_SECRET +# Edit .env — set AUTH_SECRET (required) docker compose up -d ``` The app will be available at [http://localhost:3000](http://localhost:3000). +### Adding Telegram Services + +The worker and bot run as optional profiles so `docker compose up` works with just the app + database: + +```bash +# App + DB + Telegram worker (needs TELEGRAM_API_ID + TELEGRAM_API_HASH in .env) +docker compose --profile telegram up -d + +# App + DB + Worker + Bot (also needs BOT_TOKEN in .env) +docker compose --profile full up -d + +# Or just the bot (alongside app + db) +docker compose --profile bot up -d +``` + +> **Tip:** Create a bot token via [@BotFather](https://t.me/BotFather) on Telegram and set `BOT_TOKEN` in `.env`. +> Get Telegram API credentials from [my.telegram.org/apps](https://my.telegram.org/apps). + ### Seeding the Database To seed the database with sample data on first run: @@ -157,6 +184,7 @@ docker compose build worker && docker compose up -d worker --force-recreate ```bash docker compose logs -f worker # Worker logs +docker compose logs -f bot # Bot logs docker compose logs -f app # App logs docker compose logs -f db # Database logs ``` @@ -174,10 +202,13 @@ src/ paints/ # Paint CRUD vendors/ # Vendor management locations/ # Location management - settings/ # User preferences + settings/ # User preferences + Telegram link + stls/ # STL package browser + telegram/ # Telegram admin (accounts, channels, bot sends) api/ auth/ # NextAuth API routes health/ # Health check endpoint + telegram/bot/ # Bot send API endpoints components/ layout/ # Sidebar, header, navigation shared/ # Reusable data table components @@ -197,6 +228,14 @@ worker/ util/ # Config, logger worker.ts # Main processing pipeline index.ts # Entry point + scheduler +bot/ + src/ + commands.ts # Bot command handlers (/search, /link, /subscribe, etc.) + send-listener.ts # pg_notify listener for send requests + subscriptions + tdlib/ # TDLib client with bot token auth + db/ # Database queries for links, packages, subscriptions + util/ # Config, logger + index.ts # Entry point prisma/ schema.prisma # Database schema seed.ts # Seed data @@ -231,6 +270,16 @@ Environment variables (see `.env.example`): | `MULTIPART_TIMEOUT_HOURS` | Max time span for multipart set parts (0 = no limit) | `0` | | `LOG_LEVEL` | Worker log level (`debug`, `info`, `warn`, `error`) | `info` | +### Telegram Bot + +| Variable | Description | Default | +|----------|-------------|---------| +| `BOT_TOKEN` | Bot token from [@BotFather](https://t.me/BotFather) | Optional (bot disabled if unset) | +| `TELEGRAM_API_ID` | Same API ID as worker | Required (if bot enabled) | +| `TELEGRAM_API_HASH` | Same API hash as worker | Required (if bot enabled) | +| `BOT_TDLIB_STATE_DIR` | TDLib state directory for bot | `/data/tdlib_bot` | +| `LOG_LEVEL` | Bot log level | `info` | + ## Health Check The application exposes a health check endpoint at `/api/health` that verifies database connectivity. diff --git a/bot/.gitignore b/bot/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/bot/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/bot/Dockerfile b/bot/Dockerfile new file mode 100644 index 0000000..1452394 --- /dev/null +++ b/bot/Dockerfile @@ -0,0 +1,46 @@ +# ── Stage 1: Install production deps ───────────────────────── +FROM node:20-bookworm-slim AS deps + +RUN apt-get update && apt-get install -y \ + libssl-dev zlib1g-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY bot/package.json bot/package-lock.json* ./ +COPY prisma/ ./prisma/ + +# Install ALL deps (including devDependencies for tsc) and generate Prisma +RUN npm ci && npx prisma generate + +# ── Stage 2: Build TypeScript ───────────────────────────────── +FROM deps AS builder + +COPY bot/tsconfig.json ./ +COPY bot/src/ ./src/ +RUN npx tsc + +# ── Stage 3: Production runner ──────────────────────────────── +FROM node:20-bookworm-slim AS runner + +RUN apt-get update && apt-get install -y \ + libssl3 zlib1g \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy only production node_modules +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/dist ./dist + +# Re-generate Prisma client +RUN npx prisma generate + +RUN addgroup --system botuser && adduser --system --ingroup botuser botuser +RUN mkdir -p /data/tdlib && chown -R botuser:botuser /data/tdlib +USER botuser + +VOLUME ["/data/tdlib"] + +CMD ["node", "dist/index.js"] diff --git a/bot/package-lock.json b/bot/package-lock.json new file mode 100644 index 0000000..3949f25 --- /dev/null +++ b/bot/package-lock.json @@ -0,0 +1,2100 @@ +{ + "name": "dragonsstash-bot", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dragonsstash-bot", + "version": "0.1.0", + "dependencies": { + "@prisma/adapter-pg": "^7.4.0", + "@prisma/client": "^7.4.0", + "pg": "^8.18.0", + "pino": "^9.6.0", + "prebuilt-tdlib": "^0.1008050.0", + "tdl": "^8.0.0" + }, + "devDependencies": { + "@types/node": "^20", + "@types/pg": "^8.16.0", + "prisma": "^7.4.0", + "tsx": "^4.21.0", + "typescript": "^5" + } + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz", + "integrity": "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "10.5.0", + "@chevrotain/types": "10.5.0", + "lodash": "4.17.21" + } + }, + "node_modules/@chevrotain/gast": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-10.5.0.tgz", + "integrity": "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "10.5.0", + "lodash": "4.17.21" + } + }, + "node_modules/@chevrotain/types": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-10.5.0.tgz", + "integrity": "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-10.5.0.tgz", + "integrity": "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite": { + "version": "0.3.15", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", + "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite-socket": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.0.20.tgz", + "integrity": "sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "pglite-server": "dist/scripts/server.js" + }, + "peerDependencies": { + "@electric-sql/pglite": "0.3.15" + } + }, + "node_modules/@electric-sql/pglite-tools": { + "version": "0.2.20", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.2.20.tgz", + "integrity": "sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A==", + "devOptional": true, + "license": "Apache-2.0", + "peerDependencies": { + "@electric-sql/pglite": "0.3.15" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@mrleebo/prisma-ast": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@mrleebo/prisma-ast/-/prisma-ast-0.13.1.tgz", + "integrity": "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chevrotain": "^10.5.0", + "lilconfig": "^2.1.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@prebuilt-tdlib/darwin-arm64": { + "version": "0.1008050.0", + "resolved": "https://registry.npmjs.org/@prebuilt-tdlib/darwin-arm64/-/darwin-arm64-0.1008050.0.tgz", + "integrity": "sha512-XrWN7M1gfvnzOBRX0YdXVfhSxIDSs/ZJ16QJ0ILDKe+grOFl/cfl7lwB/hK/MlHC6Rev56f5X7xaWnjMh0vktQ==", + "cpu": [ + "arm64" + ], + "license": "0BSD", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@prebuilt-tdlib/darwin-x64": { + "version": "0.1008050.0", + "resolved": "https://registry.npmjs.org/@prebuilt-tdlib/darwin-x64/-/darwin-x64-0.1008050.0.tgz", + "integrity": "sha512-a1UfBW0lYx4tUy5viMPtsbqBfBncCAgDu3FPjljfYTHjP8wfkKFxpp5+8wdxhyqdy3QriWaipVtUXQgOeEWMJg==", + "cpu": [ + "x64" + ], + "license": "0BSD", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@prebuilt-tdlib/linux-arm64-glibc": { + "version": "0.1008050.0", + "resolved": "https://registry.npmjs.org/@prebuilt-tdlib/linux-arm64-glibc/-/linux-arm64-glibc-0.1008050.0.tgz", + "integrity": "sha512-HRGspdQYzaBkU+W2M8uY5OgOkmgfTkyHkTYan/dn7EE/38QdIFW0YTvmGrl3DoFV2PA+SeJQw0xqK8tMSyHKaA==", + "cpu": [ + "arm64" + ], + "license": "0BSD", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@prebuilt-tdlib/linux-x64-glibc": { + "version": "0.1008050.0", + "resolved": "https://registry.npmjs.org/@prebuilt-tdlib/linux-x64-glibc/-/linux-x64-glibc-0.1008050.0.tgz", + "integrity": "sha512-Yf6ve3Dzxc66kV1cijFLn7EXKhPN5YHTjtJABEaCR5euetCI2wZp/1uBsXvyYTuFXqQbMfjO3xUCXUIBhLoChw==", + "cpu": [ + "x64" + ], + "license": "0BSD", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@prebuilt-tdlib/win32-x64": { + "version": "0.1008050.0", + "resolved": "https://registry.npmjs.org/@prebuilt-tdlib/win32-x64/-/win32-x64-0.1008050.0.tgz", + "integrity": "sha512-4v8tU5bodMcLhzrWWXzIzqdHBIpq0wim+7sDmQWQIMy3kDeIzVtpuM+vQjxrGoeH9oWr2WXSRKuj93ld7G5NbQ==", + "cpu": [ + "x64" + ], + "license": "0BSD", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@prisma/adapter-pg": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.4.2.tgz", + "integrity": "sha512-oUo2Zhe9Tf6YwVL8kLPuOLTK1Z2pwi/Ua77t2PuGyBan2w7shRKqHvYK+3XXmRH9RWhPJ4SMtHZKpNo6Ax/4bQ==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.4.2", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, + "node_modules/@prisma/client": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.4.2.tgz", + "integrity": "sha512-ts2mu+cQHriAhSxngO3StcYubBGTWDtu/4juZhXCUKOwgh26l+s4KD3vT2kMUzFyrYnll9u/3qWrtzRv9CGWzA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/client-runtime-utils": "7.4.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/client-runtime-utils": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.4.2.tgz", + "integrity": "sha512-cID+rzOEb38VyMsx5LwJMEY4NGIrWCNpKu/0ImbeooQ2Px7TI+kOt7cm0NelxUzF2V41UVVXAmYjANZQtCu1/Q==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/config": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.4.2.tgz", + "integrity": "sha512-CftBjWxav99lzY1Z4oDgomdb1gh9BJFAOmWF6P2v1xRfXqQb56DfBub+QKcERRdNoAzCb3HXy3Zii8Vb4AsXhg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.18.4", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.4.2.tgz", + "integrity": "sha512-aP7qzu+g/JnbF6U69LMwHoUkELiserKmWsE2shYuEpNUJ4GrtxBCvZwCyCBHFSH2kLTF2l1goBlBh4wuvRq62w==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/dev": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.20.0.tgz", + "integrity": "sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "@electric-sql/pglite": "0.3.15", + "@electric-sql/pglite-socket": "0.0.20", + "@electric-sql/pglite-tools": "0.2.20", + "@hono/node-server": "1.19.9", + "@mrleebo/prisma-ast": "0.13.1", + "@prisma/get-platform": "7.2.0", + "@prisma/query-plan-executor": "7.2.0", + "foreground-child": "3.3.1", + "get-port-please": "3.2.0", + "hono": "4.11.4", + "http-status-codes": "2.3.0", + "pathe": "2.0.3", + "proper-lockfile": "4.1.2", + "remeda": "2.33.4", + "std-env": "3.10.0", + "valibot": "1.2.0", + "zeptomatch": "2.1.0" + } + }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.4.2.tgz", + "integrity": "sha512-REdjFpT/ye9KdDs+CXAXPIbMQkVLhne9G5Pe97sNY4Ovx4r2DAbWM9hOFvvB1Oq8H8bOCdu0Ri3AoGALquQqVw==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.4.2" + } + }, + "node_modules/@prisma/engines": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.4.2.tgz", + "integrity": "sha512-B+ZZhI4rXlzjVqRw/93AothEKOU5/x4oVyJFGo9RpHPnBwaPwk4Pi0Q4iGXipKxeXPs/dqljgNBjK0m8nocOJA==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.4.2", + "@prisma/engines-version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", + "@prisma/fetch-engine": "7.4.2", + "@prisma/get-platform": "7.4.2" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919.tgz", + "integrity": "sha512-5FIKY3KoYQlBuZC2yc16EXfVRQ8HY+fLqgxkYfWCtKhRb3ajCRzP/rPeoSx11+NueJDANdh4hjY36mdmrTcGSg==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.4.2.tgz", + "integrity": "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.4.2" + } + }, + "node_modules/@prisma/fetch-engine": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.4.2.tgz", + "integrity": "sha512-f/c/MwYpdJO7taLETU8rahEstLeXfYgQGlz5fycG7Fbmva3iPdzGmjiSWHeSWIgNnlXnelUdCJqyZnFocurZuA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.4.2", + "@prisma/engines-version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", + "@prisma/get-platform": "7.4.2" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.4.2.tgz", + "integrity": "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.4.2" + } + }, + "node_modules/@prisma/get-platform": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", + "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.2.0" + } + }, + "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", + "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/query-plan-executor": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", + "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/studio-core": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.13.1.tgz", + "integrity": "sha512-agdqaPEePRHcQ7CexEfkX1RvSH9uWDb6pXrZnhCRykhDFAV0/0P3d07WtfiY8hZWb7oRU4v+NkT4cGFHkQJIPg==", + "devOptional": true, + "license": "Apache-2.0", + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.35", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", + "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pg": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/chevrotain": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz", + "integrity": "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "10.5.0", + "@chevrotain/gast": "10.5.0", + "@chevrotain/types": "10.5.0", + "@chevrotain/utils": "10.5.0", + "lodash": "4.17.21", + "regexp-to-ast": "0.5.0" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT", + "peer": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/effect": { + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", + "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-port-please": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", + "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/grammex": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", + "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/graphmatch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", + "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/hono": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", + "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "devOptional": true, + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mysql2": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/nypm": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", + "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.2.0", + "pathe": "^2.0.3", + "tinyexec": "^1.0.2" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz", + "integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz", + "integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.12.0", + "pg-protocol": "^1.12.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.12.0.tgz", + "integrity": "sha512-eIJ0DES8BLaziFHW7VgJEBPi5hg3Nyng5iKpYtj3wbcAUV9A1wLgWiY7ajf/f/oO1wfxt83phXPY8Emztg7ITg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.12.0.tgz", + "integrity": "sha512-uOANXNRACNdElMXJ0tPz6RBM0XQ61nONGAwlt8da5zs/iUOOCLBQOHSXnrC6fMsvtjxbOJrZZl5IScGv+7mpbg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "devOptional": true, + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prebuilt-tdlib": { + "version": "0.1008050.0", + "resolved": "https://registry.npmjs.org/prebuilt-tdlib/-/prebuilt-tdlib-0.1008050.0.tgz", + "integrity": "sha512-CfeQE1rG51d2iC6m72fzrbCW4mqI17ugil9pVurWHtfUJi1Fcn7zadpTzDoUl4oc1dEtKgM7S24DVP67gcl4SQ==", + "license": "MIT", + "optionalDependencies": { + "@prebuilt-tdlib/darwin-arm64": "0.1008050.0", + "@prebuilt-tdlib/darwin-x64": "0.1008050.0", + "@prebuilt-tdlib/linux-arm64-glibc": "0.1008050.0", + "@prebuilt-tdlib/linux-x64-glibc": "0.1008050.0", + "@prebuilt-tdlib/win32-x64": "0.1008050.0" + } + }, + "node_modules/prisma": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.4.2.tgz", + "integrity": "sha512-2bP8Ruww3Q95Z2eH4Yqh4KAENRsj/SxbdknIVBfd6DmjPwmpsC4OVFMLOeHt6tM3Amh8ebjvstrUz3V/hOe1dA==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "7.4.2", + "@prisma/dev": "0.20.0", + "@prisma/engines": "7.4.2", + "@prisma/studio-core": "0.13.1", + "mysql2": "3.15.3", + "postgres": "3.4.7" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "better-sqlite3": ">=9.0.0", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/regexp-to-ast": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz", + "integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/remeda": { + "version": "2.33.4", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", + "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remeda" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "devOptional": true, + "license": "MIT", + "peer": true + }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", + "devOptional": true + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/tdl": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/tdl/-/tdl-8.0.2.tgz", + "integrity": "sha512-KYxlJ4eao7FUu91U1dCDkaHmK70JAyZ1KqitkKqpPC7rxAiXWhaYxddWvt84UxIYoWbgdd0B70FYJ4p/YqpFCA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "node-addon-api": "^7.1.1", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=16.14.0" + } + }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/valibot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/zeptomatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", + "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "grammex": "^3.1.11", + "graphmatch": "^1.1.0" + } + } + } +} diff --git a/bot/package.json b/bot/package.json new file mode 100644 index 0000000..35cf5d8 --- /dev/null +++ b/bot/package.json @@ -0,0 +1,26 @@ +{ + "name": "dragonsstash-bot", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx watch src/index.ts" + }, + "dependencies": { + "@prisma/adapter-pg": "^7.4.0", + "@prisma/client": "^7.4.0", + "pg": "^8.18.0", + "pino": "^9.6.0", + "prebuilt-tdlib": "^0.1008050.0", + "tdl": "^8.0.0" + }, + "devDependencies": { + "@types/node": "^20", + "@types/pg": "^8.16.0", + "prisma": "^7.4.0", + "tsx": "^4.21.0", + "typescript": "^5" + } +} diff --git a/bot/src/commands.ts b/bot/src/commands.ts new file mode 100644 index 0000000..1841b75 --- /dev/null +++ b/bot/src/commands.ts @@ -0,0 +1,440 @@ +import { childLogger } from "./util/logger.js"; +import { + searchPackages, + getLatestPackages, + getPackageById, + findLinkByTelegramUserId, + validateLinkCode, + deleteLinkCode, + createTelegramLink, + getSubscriptions, + addSubscription, + removeSubscription, +} from "./db/queries.js"; +import { sendTextMessage, sendPhotoMessage } from "./tdlib/client.js"; + +const log = childLogger("commands"); + +interface IncomingMessage { + chatId: bigint; + userId: bigint; + text: string; + firstName: string; + lastName?: string; + username?: string; +} + +function formatSize(bytes: bigint): string { + const mb = Number(bytes) / (1024 * 1024); + if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`; + return `${mb.toFixed(1)} MB`; +} + +function formatDate(date: Date): string { + return date.toISOString().slice(0, 10); +} + +export async function handleMessage(msg: IncomingMessage): Promise { + const { chatId, userId, text } = msg; + + // Parse command and args + const trimmed = text.trim(); + const spaceIdx = trimmed.indexOf(" "); + const command = (spaceIdx > 0 ? trimmed.slice(0, spaceIdx) : trimmed).toLowerCase(); + const args = spaceIdx > 0 ? trimmed.slice(spaceIdx + 1).trim() : ""; + + try { + switch (command) { + case "/start": + await handleStart(chatId, userId, args, msg); + break; + case "/help": + await handleHelp(chatId); + break; + case "/search": + await handleSearch(chatId, args); + break; + case "/latest": + await handleLatest(chatId, args); + break; + case "/package": + await handlePackage(chatId, args); + break; + case "/link": + await handleLink(chatId, userId, args, msg); + break; + case "/unlink": + await handleUnlink(chatId, userId); + break; + case "/subscribe": + await handleSubscribe(chatId, userId, args); + break; + case "/unsubscribe": + await handleUnsubscribe(chatId, userId, args); + break; + case "/subscriptions": + await handleListSubscriptions(chatId, userId); + break; + case "/status": + await handleStatus(chatId, userId); + break; + default: + await sendTextMessage( + chatId, + "Unknown command. Use /help to see available commands.", + "textParseModeHTML" + ); + } + } catch (err) { + log.error({ err, command, userId: userId.toString() }, "Command handler error"); + await sendTextMessage( + chatId, + "An error occurred processing your command. Please try again.", + "textParseModeHTML" + ).catch(() => {}); + } +} + +async function handleStart( + chatId: bigint, + userId: bigint, + args: string, + msg: IncomingMessage +): Promise { + // Deep link: /start link_ + if (args.startsWith("link_")) { + const code = args.slice(5); + await handleLink(chatId, userId, code, msg); + return; + } + + const welcome = [ + `🐉 Dragon's Stash Bot`, + ``, + `I can help you search and receive indexed archive packages.`, + ``, + `Commands:`, + `/search <query> — Search packages`, + `/latest [n] — Show latest packages`, + `/package <id> — Package details`, + `/link <code> — Link your Telegram to your web account`, + `/subscribe <keyword> — Get notified for new packages`, + `/subscriptions — View your subscriptions`, + `/unsubscribe <keyword> — Remove a subscription`, + `/status — Check your link status`, + `/help — Show this help message`, + ].join("\n"); + + await sendTextMessage(chatId, welcome, "textParseModeHTML"); +} + +async function handleHelp(chatId: bigint): Promise { + const help = [ + `Available Commands:`, + ``, + `🔍 Search & Browse`, + `/search <query> — Search by filename or creator`, + `/latest [n] — Show n most recent packages (default: 5)`, + `/package <id> — View package details and file list`, + ``, + `🔗 Account Linking`, + `/link <code> — Link Telegram to your web account`, + `/unlink — Unlink your Telegram account`, + `/status — Check link status`, + ``, + `🔔 Notifications`, + `/subscribe <keyword> — Get alerts for matching packages`, + `/unsubscribe <keyword> — Remove a subscription`, + `/subscriptions — List your subscriptions`, + ].join("\n"); + + await sendTextMessage(chatId, help, "textParseModeHTML"); +} + +async function handleSearch(chatId: bigint, query: string): Promise { + if (!query) { + await sendTextMessage(chatId, "Usage: /search <query>", "textParseModeHTML"); + return; + } + + const results = await searchPackages(query, 10); + + if (results.length === 0) { + await sendTextMessage( + chatId, + `No packages found for "${escapeHtml(query)}".`, + "textParseModeHTML" + ); + return; + } + + const lines = results.map((pkg, i) => { + const creator = pkg.creator ? ` by ${pkg.creator}` : ""; + return `${i + 1}. ${escapeHtml(pkg.fileName)}${creator}\n 📦 ${pkg.fileCount} files · ${formatSize(pkg.fileSize)} · ${formatDate(pkg.indexedAt)}\n ID: ${pkg.id}`; + }); + + const response = [ + `🔍 Search results for "${escapeHtml(query)}":`, + ``, + ...lines, + ``, + `Use /package <id> for details.`, + ].join("\n"); + + await sendTextMessage(chatId, response, "textParseModeHTML"); +} + +async function handleLatest(chatId: bigint, args: string): Promise { + const limit = Math.min(Math.max(parseInt(args) || 5, 1), 20); + const results = await getLatestPackages(limit); + + if (results.length === 0) { + await sendTextMessage(chatId, "No packages indexed yet.", "textParseModeHTML"); + return; + } + + const lines = results.map((pkg, i) => { + const creator = pkg.creator ? ` by ${pkg.creator}` : ""; + return `${i + 1}. ${escapeHtml(pkg.fileName)}${creator}\n 📦 ${pkg.fileCount} files · ${formatSize(pkg.fileSize)} · ${formatDate(pkg.indexedAt)}\n ID: ${pkg.id}`; + }); + + const response = [ + `📋 Latest ${results.length} packages:`, + ``, + ...lines, + ``, + `Use /package <id> for details.`, + ].join("\n"); + + await sendTextMessage(chatId, response, "textParseModeHTML"); +} + +async function handlePackage(chatId: bigint, id: string): Promise { + if (!id) { + await sendTextMessage(chatId, "Usage: /package <id>", "textParseModeHTML"); + return; + } + + const pkg = await getPackageById(id.trim()); + if (!pkg) { + await sendTextMessage(chatId, "Package not found.", "textParseModeHTML"); + return; + } + + const fileList = pkg.files + .slice(0, 15) + .map((f) => ` ${escapeHtml(f.path)}`) + .join("\n"); + const moreFiles = pkg.files.length > 15 ? `\n ... and ${pkg.fileCount - 15} more` : ""; + + const details = [ + `📦 ${escapeHtml(pkg.fileName)}`, + ``, + `Type: ${pkg.archiveType}`, + `Size: ${formatSize(pkg.fileSize)}`, + `Files: ${pkg.fileCount}`, + pkg.creator ? `Creator: ${escapeHtml(pkg.creator)}` : null, + `Source: ${escapeHtml(pkg.sourceChannel.title)}`, + `Indexed: ${formatDate(pkg.indexedAt)}`, + pkg.isMultipart ? `Parts: ${pkg.partCount}` : null, + ``, + `File listing:`, + `${fileList}${moreFiles}`, + ] + .filter(Boolean) + .join("\n"); + + // Send preview if available + if (pkg.previewData) { + await sendPhotoMessage( + chatId, + Buffer.from(pkg.previewData), + details + ); + } else { + await sendTextMessage(chatId, details, "textParseModeHTML"); + } +} + +async function handleLink( + chatId: bigint, + userId: bigint, + code: string, + msg: IncomingMessage +): Promise { + if (!code) { + await sendTextMessage( + chatId, + "Usage: /link <code>\n\nGet your link code from Settings → Telegram in the web app.", + "textParseModeHTML" + ); + return; + } + + // Check if already linked + const existing = await findLinkByTelegramUserId(userId); + if (existing) { + await sendTextMessage( + chatId, + "Your Telegram account is already linked to a web account. Use /unlink first if you want to re-link.", + "textParseModeHTML" + ); + return; + } + + // Validate the code + const webUserId = await validateLinkCode(code.trim()); + if (!webUserId) { + await sendTextMessage( + chatId, + "Invalid or expired link code. Please generate a new one from Settings → Telegram.", + "textParseModeHTML" + ); + return; + } + + // Create the link + const displayName = [msg.firstName, msg.lastName].filter(Boolean).join(" "); + await createTelegramLink(webUserId, userId, displayName || msg.username || null); + await deleteLinkCode(code.trim()); + + await sendTextMessage( + chatId, + `✅ Account linked successfully!\n\nYou can now receive packages sent from the web app. Use /status to verify.`, + "textParseModeHTML" + ); + + log.info({ userId: userId.toString(), webUserId }, "Telegram account linked"); +} + +async function handleUnlink(chatId: bigint, userId: bigint): Promise { + const existing = await findLinkByTelegramUserId(userId); + if (!existing) { + await sendTextMessage( + chatId, + "Your Telegram account is not linked to any web account.", + "textParseModeHTML" + ); + return; + } + + const { db } = await import("./db/client.js"); + await db.telegramLink.delete({ where: { telegramUserId: userId } }); + + await sendTextMessage( + chatId, + "🔓 Account unlinked. You will no longer receive packages from the web app.", + "textParseModeHTML" + ); + + log.info({ userId: userId.toString() }, "Telegram account unlinked"); +} + +async function handleSubscribe( + chatId: bigint, + userId: bigint, + pattern: string +): Promise { + if (!pattern) { + await sendTextMessage( + chatId, + "Usage: /subscribe <keyword>\n\nYou'll be notified when new packages matching this keyword are indexed.", + "textParseModeHTML" + ); + return; + } + + await addSubscription(userId, pattern.toLowerCase().trim()); + + await sendTextMessage( + chatId, + `🔔 Subscribed to "${escapeHtml(pattern.trim())}".\n\nYou'll be notified when matching packages are indexed.`, + "textParseModeHTML" + ); +} + +async function handleUnsubscribe( + chatId: bigint, + userId: bigint, + pattern: string +): Promise { + if (!pattern) { + await sendTextMessage( + chatId, + "Usage: /unsubscribe <keyword>", + "textParseModeHTML" + ); + return; + } + + const result = await removeSubscription(userId, pattern.toLowerCase().trim()); + + if (result.count === 0) { + await sendTextMessage( + chatId, + `No subscription found for "${escapeHtml(pattern.trim())}".`, + "textParseModeHTML" + ); + } else { + await sendTextMessage( + chatId, + `🔕 Unsubscribed from "${escapeHtml(pattern.trim())}".`, + "textParseModeHTML" + ); + } +} + +async function handleListSubscriptions( + chatId: bigint, + userId: bigint +): Promise { + const subs = await getSubscriptions(userId); + + if (subs.length === 0) { + await sendTextMessage( + chatId, + "You have no active subscriptions. Use /subscribe <keyword> to add one.", + "textParseModeHTML" + ); + return; + } + + const lines = subs.map( + (s, i) => `${i + 1}. ${escapeHtml(s.pattern)} (since ${formatDate(s.createdAt)})` + ); + + const response = [ + `🔔 Your subscriptions:`, + ``, + ...lines, + ``, + `Use /unsubscribe <keyword> to remove one.`, + ].join("\n"); + + await sendTextMessage(chatId, response, "textParseModeHTML"); +} + +async function handleStatus(chatId: bigint, userId: bigint): Promise { + const link = await findLinkByTelegramUserId(userId); + + if (link) { + await sendTextMessage( + chatId, + `✅ Linked\n\nYour Telegram account is linked to a web account.\nLinked since: ${formatDate(link.createdAt)}`, + "textParseModeHTML" + ); + } else { + await sendTextMessage( + chatId, + `❌ Not linked\n\nUse /link <code> to connect your web account.`, + "textParseModeHTML" + ); + } +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">"); +} diff --git a/bot/src/db/client.ts b/bot/src/db/client.ts new file mode 100644 index 0000000..4913c8a --- /dev/null +++ b/bot/src/db/client.ts @@ -0,0 +1,13 @@ +import { PrismaClient } from "@prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; +import pg from "pg"; +import { config } from "../util/config.js"; + +const pool = new pg.Pool({ + connectionString: config.databaseUrl, + max: 5, +}); + +const adapter = new PrismaPg(pool); +export const db = new PrismaClient({ adapter }); +export { pool }; diff --git a/bot/src/db/queries.ts b/bot/src/db/queries.ts new file mode 100644 index 0000000..1bd2cd8 --- /dev/null +++ b/bot/src/db/queries.ts @@ -0,0 +1,180 @@ +import { db } from "./client.js"; + +// ── Link management ── + +export async function findLinkByTelegramUserId(telegramUserId: bigint) { + return db.telegramLink.findUnique({ + where: { telegramUserId }, + }); +} + +export async function findLinkByUserId(userId: string) { + return db.telegramLink.findUnique({ + where: { userId }, + }); +} + +/** + * Validate a link code stored in global_settings as `link_code:`. + * Returns the userId if the code is valid, null otherwise. + */ +export async function validateLinkCode(code: string): Promise { + const key = `link_code:${code}`; + const setting = await db.globalSetting.findUnique({ where: { key } }); + return setting?.value ?? null; +} + +export async function deleteLinkCode(code: string): Promise { + const key = `link_code:${code}`; + await db.globalSetting.delete({ where: { key } }).catch(() => {}); +} + +export async function createTelegramLink( + userId: string, + telegramUserId: bigint, + telegramName: string | null +) { + return db.telegramLink.upsert({ + where: { userId }, + create: { userId, telegramUserId, telegramName }, + update: { telegramUserId, telegramName }, + }); +} + +// ── Package search ── + +export async function searchPackages(query: string, limit = 10) { + const packages = await db.package.findMany({ + where: { + OR: [ + { fileName: { contains: query, mode: "insensitive" } }, + { creator: { contains: query, mode: "insensitive" } }, + ], + }, + orderBy: { indexedAt: "desc" }, + take: limit, + select: { + id: true, + fileName: true, + fileSize: true, + archiveType: true, + fileCount: true, + creator: true, + indexedAt: true, + destChannelId: true, + destMessageId: true, + }, + }); + return packages; +} + +export async function getLatestPackages(limit = 5) { + return db.package.findMany({ + orderBy: { indexedAt: "desc" }, + take: limit, + select: { + id: true, + fileName: true, + fileSize: true, + archiveType: true, + fileCount: true, + creator: true, + indexedAt: true, + destChannelId: true, + destMessageId: true, + }, + }); +} + +export async function getPackageById(id: string) { + return db.package.findUnique({ + where: { id }, + include: { + files: { take: 20, orderBy: { path: "asc" } }, + sourceChannel: { select: { title: true } }, + }, + }); +} + +// ── Send requests ── + +export async function getPendingSendRequest(requestId: string) { + return db.botSendRequest.findUnique({ + where: { id: requestId }, + include: { + package: { + select: { + id: true, + fileName: true, + destChannelId: true, + destMessageId: true, + previewData: true, + }, + }, + telegramLink: true, + }, + }); +} + +export async function updateSendRequest( + requestId: string, + status: "SENDING" | "SENT" | "FAILED", + error?: string +) { + return db.botSendRequest.update({ + where: { id: requestId }, + data: { + status, + error: error ?? undefined, + completedAt: status === "SENT" || status === "FAILED" ? new Date() : undefined, + }, + }); +} + +// ── Subscriptions ── + +export async function getSubscriptions(telegramUserId: bigint) { + return db.botSubscription.findMany({ + where: { telegramUserId }, + orderBy: { createdAt: "desc" }, + }); +} + +export async function addSubscription(telegramUserId: bigint, pattern: string) { + return db.botSubscription.upsert({ + where: { + telegramUserId_pattern: { telegramUserId, pattern }, + }, + create: { telegramUserId, pattern }, + update: {}, + }); +} + +export async function removeSubscription(telegramUserId: bigint, pattern: string) { + return db.botSubscription.deleteMany({ + where: { telegramUserId, pattern }, + }); +} + +export async function findMatchingSubscriptions(fileName: string, creator: string | null) { + // Get all subscriptions and filter in-memory (simpler for pattern matching) + const subs = await db.botSubscription.findMany(); + return subs.filter((sub) => { + const p = sub.pattern.toLowerCase(); + if (fileName.toLowerCase().includes(p)) return true; + if (creator && creator.toLowerCase().includes(p)) return true; + return false; + }); +} + +// ── Destination channel ── + +export async function getGlobalDestinationChannel() { + const setting = await db.globalSetting.findUnique({ + where: { key: "destination_channel_id" }, + }); + if (!setting) return null; + return db.telegramChannel.findFirst({ + where: { id: setting.value, type: "DESTINATION", isActive: true }, + }); +} diff --git a/bot/src/index.ts b/bot/src/index.ts new file mode 100644 index 0000000..be2d097 --- /dev/null +++ b/bot/src/index.ts @@ -0,0 +1,92 @@ +import { config } from "./util/config.js"; +import { logger } from "./util/logger.js"; +import { db, pool } from "./db/client.js"; +import { createBotClient, closeBotClient, onBotUpdate } from "./tdlib/client.js"; +import { startSendListener, stopSendListener } from "./send-listener.js"; +import { handleMessage } from "./commands.js"; +import { mkdir } from "fs/promises"; + +const log = logger.child({ module: "main" }); + +async function main(): Promise { + log.info("DragonsStash Telegram Bot starting"); + + if (!config.botToken) { + log.fatal("BOT_TOKEN environment variable is required"); + process.exit(1); + } + + if (!config.telegramApiId || !config.telegramApiHash) { + log.fatal("TELEGRAM_API_ID and TELEGRAM_API_HASH are required"); + process.exit(1); + } + + // Ensure TDLib state directory exists + await mkdir(config.tdlibStateDir, { recursive: true }); + await mkdir(`${config.tdlibStateDir}/bot`, { recursive: true }); + await mkdir(`${config.tdlibStateDir}/bot_files`, { recursive: true }); + + // Initialize TDLib bot client + await createBotClient(); + + // Start pg_notify listener for send requests and new package notifications + await startSendListener(); + + // Listen for incoming messages from Telegram users + onBotUpdate((update) => { + if (update._ === "updateNewMessage") { + const message = update.message as Record; + const content = message.content as Record; + const chatId = message.chat_id as number; + const senderId = message.sender_id as Record | undefined; + + // Only handle text messages from users (not channels or service messages) + if ( + content?._ === "messageText" && + senderId?._ === "messageSenderUser" + ) { + const text = (content.text as Record)?.text as string; + const userId = senderId.user_id as number; + + if (text && userId) { + // Get user info for display name (async but fire-and-forget for perf) + handleMessage({ + chatId: BigInt(chatId), + userId: BigInt(userId), + text, + firstName: "User", // TDLib provides this via a separate getUser call + username: undefined, + }).catch((err) => { + log.error({ err, chatId, userId }, "Failed to handle message"); + }); + } + } + } + }); + + log.info("Bot is running and listening for messages"); +} + +// Graceful shutdown +function shutdown(signal: string): void { + log.info({ signal }, "Shutdown signal received"); + stopSendListener(); + + Promise.all([closeBotClient(), db.$disconnect(), pool.end()]) + .then(() => { + log.info("Shutdown complete"); + process.exit(0); + }) + .catch((err) => { + log.error({ err }, "Error during shutdown"); + process.exit(1); + }); +} + +process.on("SIGTERM", () => shutdown("SIGTERM")); +process.on("SIGINT", () => shutdown("SIGINT")); + +main().catch((err) => { + log.fatal({ err }, "Bot failed to start"); + process.exit(1); +}); diff --git a/bot/src/send-listener.ts b/bot/src/send-listener.ts new file mode 100644 index 0000000..4389733 --- /dev/null +++ b/bot/src/send-listener.ts @@ -0,0 +1,162 @@ +import type pg from "pg"; +import { pool } from "./db/client.js"; +import { childLogger } from "./util/logger.js"; +import { + getPendingSendRequest, + updateSendRequest, + findMatchingSubscriptions, + getGlobalDestinationChannel, +} from "./db/queries.js"; +import { copyMessageToUser, sendTextMessage, sendPhotoMessage } from "./tdlib/client.js"; + +const log = childLogger("send-listener"); + +let pgClient: pg.PoolClient | null = null; + +/** + * Start listening for pg_notify signals: + * - `bot_send` — payload = requestId → send a package to a user + * - `new_package` — payload = JSON { packageId, fileName, creator } → notify subscribers + */ +export async function startSendListener(): Promise { + pgClient = await pool.connect(); + await pgClient.query("LISTEN bot_send"); + await pgClient.query("LISTEN new_package"); + + pgClient.on("notification", (msg) => { + if (msg.channel === "bot_send" && msg.payload) { + handleBotSend(msg.payload); + } else if (msg.channel === "new_package" && msg.payload) { + handleNewPackage(msg.payload); + } + }); + + log.info("Send listener started (bot_send, new_package)"); +} + +export function stopSendListener(): void { + if (pgClient) { + pgClient.release(); + pgClient = null; + } + log.info("Send listener stopped"); +} + +// ── bot_send handler ── + +let sendQueue: Promise = Promise.resolve(); + +function handleBotSend(requestId: string): void { + sendQueue = sendQueue.then(() => processSendRequest(requestId)).catch((err) => { + log.error({ err, requestId }, "Send request processing failed"); + }); +} + +async function processSendRequest(requestId: string): Promise { + const request = await getPendingSendRequest(requestId); + if (!request || request.status !== "PENDING") { + log.warn({ requestId }, "Send request not found or not pending"); + return; + } + + log.info( + { + requestId, + packageId: request.packageId, + targetTgId: request.telegramLink.telegramUserId.toString(), + }, + "Processing send request" + ); + + await updateSendRequest(requestId, "SENDING"); + + try { + const pkg = request.package; + const targetUserId = request.telegramLink.telegramUserId; + + if (!pkg.destChannelId || !pkg.destMessageId) { + throw new Error("Package has no destination message — cannot forward"); + } + + // Get the destination channel's Telegram ID + const destChannel = await getGlobalDestinationChannel(); + if (!destChannel) { + throw new Error("No global destination channel configured"); + } + + // Send preview if available + if (pkg.previewData) { + const caption = `📦 *${pkg.fileName}*\n\nSent from Dragon's Stash`; + await sendPhotoMessage(targetUserId, Buffer.from(pkg.previewData), caption); + } + + // Forward the actual archive file(s) from destination channel + await copyMessageToUser( + destChannel.telegramId, + pkg.destMessageId, + targetUserId + ); + + await updateSendRequest(requestId, "SENT"); + log.info({ requestId }, "Send request completed successfully"); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.error({ err, requestId }, "Send request failed"); + await updateSendRequest(requestId, "FAILED", message); + } +} + +// ── new_package handler ── + +async function handleNewPackage(payload: string): Promise { + try { + const data = JSON.parse(payload) as { + packageId: string; + fileName: string; + creator: string | null; + }; + + const subs = await findMatchingSubscriptions(data.fileName, data.creator); + if (subs.length === 0) return; + + log.info( + { packageId: data.packageId, matchedSubscriptions: subs.length }, + "Notifying subscribers of new package" + ); + + // Group by user to send one notification per user + const userSubs = new Map(); + for (const sub of subs) { + const key = sub.telegramUserId.toString(); + const patterns = userSubs.get(key) ?? []; + patterns.push(sub.pattern); + userSubs.set(key, patterns); + } + + const creator = data.creator ? ` by ${data.creator}` : ""; + for (const [telegramUserId, patterns] of userSubs) { + const msg = [ + `🔔 New package matching your subscriptions:`, + ``, + `📦 ${escapeHtml(data.fileName)}${creator}`, + ``, + `Matched: ${patterns.map((p) => `"${escapeHtml(p)}"`).join(", ")}`, + ``, + `Use /package ${data.packageId} for details.`, + ].join("\n"); + + await sendTextMessage(BigInt(telegramUserId), msg, "textParseModeHTML").catch((err) => { + log.warn( + { err, telegramUserId, packageId: data.packageId }, + "Failed to notify subscriber" + ); + }); + } + } catch (err) { + log.error({ err, payload }, "Failed to process new_package notification"); + } +} + +function escapeHtml(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">"); +} diff --git a/bot/src/tdlib/client.ts b/bot/src/tdlib/client.ts new file mode 100644 index 0000000..19a2110 --- /dev/null +++ b/bot/src/tdlib/client.ts @@ -0,0 +1,155 @@ +import tdl from "tdl"; +import { getTdjson } from "prebuilt-tdlib"; +import { config } from "../util/config.js"; +import { childLogger } from "../util/logger.js"; + +const log = childLogger("tdlib-bot"); + +tdl.configure({ tdjson: getTdjson() }); + +let client: tdl.Client | null = null; + +/** + * Create and authenticate a TDLib client using the bot token. + * Bot accounts have different capabilities from user accounts — + * they can't read channel history but can send/forward/copy messages + * to users who have interacted with them. + */ +export async function createBotClient(): Promise { + if (client) return client; + + log.info("Creating TDLib bot client"); + + client = tdl.createClient({ + apiId: config.telegramApiId, + apiHash: config.telegramApiHash, + databaseDirectory: `${config.tdlibStateDir}/bot`, + filesDirectory: `${config.tdlibStateDir}/bot_files`, + }); + + client.on("error", (err) => { + log.error({ err }, "TDLib client error"); + }); + + await client.login(() => ({ + type: "bot", + token: config.botToken, + })); + + log.info("Bot client authenticated successfully"); + return client; +} + +export async function closeBotClient(): Promise { + if (client) { + try { + await client.close(); + } catch { + // Ignore close errors + } + client = null; + log.info("Bot client closed"); + } +} + +/** + * Forward a message from a channel to a user's DM. + * Uses copyMessage to make it appear as sent by the bot. + */ +export async function copyMessageToUser( + fromChatId: bigint, + messageId: bigint, + toUserId: bigint +): Promise { + if (!client) throw new Error("Bot client not initialized"); + + // TDLib uses negative chat IDs for channels/supergroups + // The telegramId from the DB is the raw Telegram ID; for channels it needs -100 prefix + const fromChatIdNum = Number(-100n * 1n) + Number(fromChatId); + + await client.invoke({ + _: "forwardMessages", + chat_id: Number(toUserId), + from_chat_id: Number(fromChatId) > 0 ? -Number(fromChatId) : Number(fromChatId), + message_ids: [Number(messageId)], + send_copy: true, + remove_caption: false, + }); +} + +/** + * Send a text message to a user. + */ +export async function sendTextMessage( + chatId: bigint, + text: string, + parseMode: "textParseModeMarkdown" | "textParseModeHTML" = "textParseModeMarkdown" +): Promise { + if (!client) throw new Error("Bot client not initialized"); + + // Parse the text first + const parsed = await client.invoke({ + _: "parseTextEntities", + text, + parse_mode: { _: parseMode, version: parseMode === "textParseModeMarkdown" ? 2 : 0 }, + }); + + await client.invoke({ + _: "sendMessage", + chat_id: Number(chatId), + input_message_content: { + _: "inputMessageText", + text: parsed, + }, + }); +} + +/** + * Send a photo with caption to a user (for preview images). + */ +export async function sendPhotoMessage( + chatId: bigint, + photoData: Buffer, + caption: string +): Promise { + if (!client) throw new Error("Bot client not initialized"); + + // Write the photo to a temp file + const { writeFile, unlink } = await import("fs/promises"); + const path = await import("path"); + const tempPath = path.join(config.tdlibStateDir, `preview_${Date.now()}.jpg`); + + try { + await writeFile(tempPath, photoData); + + const parsedCaption = await client.invoke({ + _: "parseTextEntities", + text: caption, + parse_mode: { _: "textParseModeMarkdown", version: 2 }, + }); + + await client.invoke({ + _: "sendMessage", + chat_id: Number(chatId), + input_message_content: { + _: "inputMessagePhoto", + photo: { _: "inputFileLocal", path: tempPath }, + caption: parsedCaption, + width: 0, + height: 0, + }, + }); + } finally { + await unlink(tempPath).catch(() => {}); + } +} + +/** + * Get updates from TDLib. The bot listens for new messages this way. + */ +export function onBotUpdate( + handler: (update: Record) => void +): void { + if (!client) throw new Error("Bot client not initialized"); + client.on("update", handler); +} diff --git a/bot/src/util/config.ts b/bot/src/util/config.ts new file mode 100644 index 0000000..9017695 --- /dev/null +++ b/bot/src/util/config.ts @@ -0,0 +1,8 @@ +export const config = { + databaseUrl: process.env.DATABASE_URL ?? "", + botToken: process.env.BOT_TOKEN ?? "", + telegramApiId: parseInt(process.env.TELEGRAM_API_ID ?? "0", 10), + telegramApiHash: process.env.TELEGRAM_API_HASH ?? "", + logLevel: (process.env.LOG_LEVEL ?? "info") as "debug" | "info" | "warn" | "error", + tdlibStateDir: process.env.TDLIB_STATE_DIR ?? "/data/tdlib", +} as const; diff --git a/bot/src/util/logger.ts b/bot/src/util/logger.ts new file mode 100644 index 0000000..9efbaaa --- /dev/null +++ b/bot/src/util/logger.ts @@ -0,0 +1,14 @@ +import pino from "pino"; +import { config } from "./config.js"; + +export const logger = pino({ + level: config.logLevel, + transport: + config.logLevel === "debug" + ? { target: "pino/file", options: { destination: 1 } } + : undefined, +}); + +export function childLogger(module: string, extra?: Record) { + return logger.child({ module, ...extra }); +} diff --git a/bot/tsconfig.json b/bot/tsconfig.json new file mode 100644 index 0000000..18139e3 --- /dev/null +++ b/bot/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "declaration": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 6566bf5..a420816 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -4,25 +4,26 @@ services: ports: - "5432:5432" environment: - POSTGRES_USER: dragons - POSTGRES_PASSWORD: stash - POSTGRES_DB: dragonsstash + POSTGRES_USER: ${POSTGRES_USER:-dragons} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-stash} + POSTGRES_DB: ${POSTGRES_DB:-dragonsstash} volumes: - postgres_dev_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U dragons -d dragonsstash"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dragons} -d ${POSTGRES_DB:-dragonsstash}"] interval: 10s timeout: 5s retries: 5 worker: + profiles: ["telegram", "full"] build: context: . dockerfile: worker/Dockerfile env_file: - .env.local environment: - - DATABASE_URL=postgresql://dragons:stash@db:5432/dragonsstash + - DATABASE_URL=postgresql://${POSTGRES_USER:-dragons}:${POSTGRES_PASSWORD:-stash}@db:5432/${POSTGRES_DB:-dragonsstash} - WORKER_INTERVAL_MINUTES=5 - WORKER_TEMP_DIR=/tmp/zips - TDLIB_STATE_DIR=/data/tdlib @@ -35,7 +36,24 @@ services: db: condition: service_healthy + bot: + profiles: ["bot", "full"] + build: + context: . + dockerfile: bot/Dockerfile + env_file: + - .env.local + environment: + - DATABASE_URL=postgresql://${POSTGRES_USER:-dragons}:${POSTGRES_PASSWORD:-stash}@db:5432/${POSTGRES_DB:-dragonsstash} + - LOG_LEVEL=debug + volumes: + - tdlib_dev_bot_state:/data/tdlib + depends_on: + db: + condition: service_healthy + volumes: postgres_dev_data: tdlib_dev_state: + tdlib_dev_bot_state: tmp_dev_zips: diff --git a/docker-compose.yml b/docker-compose.yml index 4118fa8..ec4b69b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,29 +4,46 @@ services: context: . dockerfile: Dockerfile ports: - - "3000:3000" + - "${APP_PORT:-3000}:3000" environment: - - DATABASE_URL=postgresql://dragons:stash@db:5432/dragonsstash - - AUTH_SECRET=change-me-to-a-random-secret-in-production + - DATABASE_URL=postgresql://${POSTGRES_USER:-dragons}:${POSTGRES_PASSWORD:-stash}@db:5432/${POSTGRES_DB:-dragonsstash} + - AUTH_SECRET=${AUTH_SECRET:?Set AUTH_SECRET in .env} - AUTH_TRUST_HOST=true - - NEXT_PUBLIC_APP_URL=http://localhost:3000 + - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} - TELEGRAM_API_KEY=${TELEGRAM_API_KEY:-} + - BOT_TOKEN=${BOT_TOKEN:-} depends_on: db: condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s restart: unless-stopped + deploy: + resources: + limits: + memory: 512M + networks: + - frontend worker: + profiles: ["telegram", "full"] build: context: . dockerfile: worker/Dockerfile environment: - - DATABASE_URL=postgresql://dragons:stash@db:5432/dragonsstash - - WORKER_INTERVAL_MINUTES=60 + - DATABASE_URL=postgresql://${POSTGRES_USER:-dragons}:${POSTGRES_PASSWORD:-stash}@db:5432/${POSTGRES_DB:-dragonsstash} + - TELEGRAM_API_ID=${TELEGRAM_API_ID:?Set TELEGRAM_API_ID in .env} + - TELEGRAM_API_HASH=${TELEGRAM_API_HASH:?Set TELEGRAM_API_HASH in .env} + - WORKER_INTERVAL_MINUTES=${WORKER_INTERVAL_MINUTES:-60} - WORKER_TEMP_DIR=/tmp/zips - TDLIB_STATE_DIR=/data/tdlib - - WORKER_MAX_ZIP_SIZE_MB=4096 - - LOG_LEVEL=info + - WORKER_MAX_ZIP_SIZE_MB=${WORKER_MAX_ZIP_SIZE_MB:-4096} + - MULTIPART_TIMEOUT_HOURS=${MULTIPART_TIMEOUT_HOURS:-0} + - LOG_LEVEL=${LOG_LEVEL:-info} volumes: - tdlib_state:/data/tdlib - tmp_zips:/tmp/zips @@ -40,25 +57,63 @@ services: memory: 1G reservations: memory: 256M + networks: + - backend + + bot: + profiles: ["bot", "full"] + build: + context: . + dockerfile: bot/Dockerfile + environment: + - DATABASE_URL=postgresql://${POSTGRES_USER:-dragons}:${POSTGRES_PASSWORD:-stash}@db:5432/${POSTGRES_DB:-dragonsstash} + - BOT_TOKEN=${BOT_TOKEN:?Set BOT_TOKEN in .env} + - TELEGRAM_API_ID=${TELEGRAM_API_ID:?Set TELEGRAM_API_ID in .env} + - TELEGRAM_API_HASH=${TELEGRAM_API_HASH:?Set TELEGRAM_API_HASH in .env} + - LOG_LEVEL=${LOG_LEVEL:-info} + volumes: + - tdlib_bot_state:/data/tdlib + depends_on: + db: + condition: service_healthy + restart: unless-stopped + deploy: + resources: + limits: + memory: 512M + reservations: + memory: 128M + networks: + - backend db: image: postgres:16-alpine - ports: - - "5432:5432" environment: - POSTGRES_USER: dragons - POSTGRES_PASSWORD: stash - POSTGRES_DB: dragonsstash + POSTGRES_USER: ${POSTGRES_USER:-dragons} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-stash} + POSTGRES_DB: ${POSTGRES_DB:-dragonsstash} volumes: - postgres_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U dragons -d dragonsstash"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dragons} -d ${POSTGRES_DB:-dragonsstash}"] interval: 10s timeout: 5s retries: 5 restart: unless-stopped + deploy: + resources: + limits: + memory: 1G + networks: + - frontend + - backend volumes: postgres_data: tdlib_state: + tdlib_bot_state: tmp_zips: + +networks: + frontend: + backend: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 01e514e..df27d53 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,6 +1,14 @@ #!/bin/sh set -e +# Guard: refuse to start with the placeholder AUTH_SECRET +if [ "$AUTH_SECRET" = "change-me-to-a-random-secret-in-production" ] || [ -z "$AUTH_SECRET" ]; then + echo "ERROR: AUTH_SECRET is not set or still uses the placeholder value." + echo "Generate one with: openssl rand -base64 32" + echo "Then set it in your .env file." + exit 1 +fi + echo "Running database migrations..." npx prisma migrate deploy diff --git a/package-lock.json b/package-lock.json index 572e4cc..c905614 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "dragons-stash", "version": "0.1.0", + "hasInstallScript": true, "dependencies": { "@auth/prisma-adapter": "^2.11.1", "@hookform/resolvers": "^5.2.2", diff --git a/prisma/migrations/20260303200000_add_telegram_bot/migration.sql b/prisma/migrations/20260303200000_add_telegram_bot/migration.sql new file mode 100644 index 0000000..9a96576 --- /dev/null +++ b/prisma/migrations/20260303200000_add_telegram_bot/migration.sql @@ -0,0 +1,67 @@ +-- CreateEnum +CREATE TYPE "BotSendStatus" AS ENUM ('PENDING', 'SENDING', 'SENT', 'FAILED'); + +-- CreateTable +CREATE TABLE "telegram_links" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "telegramUserId" BIGINT NOT NULL, + "telegramName" VARCHAR(128), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "telegram_links_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "bot_send_requests" ( + "id" TEXT NOT NULL, + "packageId" TEXT NOT NULL, + "telegramLinkId" TEXT NOT NULL, + "requestedByUserId" TEXT NOT NULL, + "status" "BotSendStatus" NOT NULL DEFAULT 'PENDING', + "error" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completedAt" TIMESTAMP(3), + + CONSTRAINT "bot_send_requests_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "bot_subscriptions" ( + "id" TEXT NOT NULL, + "telegramUserId" BIGINT NOT NULL, + "pattern" VARCHAR(256) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "bot_subscriptions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "telegram_links_userId_key" ON "telegram_links"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "telegram_links_telegramUserId_key" ON "telegram_links"("telegramUserId"); + +-- CreateIndex +CREATE INDEX "bot_send_requests_status_idx" ON "bot_send_requests"("status"); + +-- CreateIndex +CREATE INDEX "bot_send_requests_telegramLinkId_idx" ON "bot_send_requests"("telegramLinkId"); + +-- CreateIndex +CREATE INDEX "bot_send_requests_createdAt_idx" ON "bot_send_requests"("createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "bot_subscriptions_telegramUserId_pattern_key" ON "bot_subscriptions"("telegramUserId", "pattern"); + +-- CreateIndex +CREATE INDEX "bot_subscriptions_telegramUserId_idx" ON "bot_subscriptions"("telegramUserId"); + +-- AddForeignKey +ALTER TABLE "telegram_links" ADD CONSTRAINT "telegram_links_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "bot_send_requests" ADD CONSTRAINT "bot_send_requests_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "packages"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "bot_send_requests" ADD CONSTRAINT "bot_send_requests_telegramLinkId_fkey" FOREIGN KEY ("telegramLinkId") REFERENCES "telegram_links"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3705232..ddd8874 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4,6 +4,7 @@ generator client { datasource db { provider = "postgresql" + url = env("DATABASE_URL") } // ─────────────────────────────────────── @@ -34,9 +35,10 @@ model User { supplies Supply[] vendors Vendor[] locations Location[] - usageLogs UsageLog[] - tags Tag[] - settings UserSettings? + usageLogs UsageLog[] + tags Tag[] + settings UserSettings? + telegramLink TelegramLink? } model Account { @@ -469,6 +471,7 @@ model Package { files PackageFile[] ingestionRun IngestionRun? @relation(fields: [ingestionRunId], references: [id]) ingestionRunId String? + sendRequests BotSendRequest[] @@index([sourceChannelId]) @@index([destChannelId]) @@ -566,3 +569,61 @@ model ChannelFetchRequest { @@index([accountId, status]) @@map("channel_fetch_requests") } + +// ─────────────────────────────────────── +// Telegram Bot models +// ─────────────────────────────────────── + +enum BotSendStatus { + PENDING + SENDING + SENT + FAILED +} + +/// Links a NextAuth user to a Telegram user ID. +/// Created when a user sends /link to the bot. +model TelegramLink { + id String @id @default(cuid()) + userId String @unique + telegramUserId BigInt @unique + telegramName String? @db.VarChar(128) + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + sendRequests BotSendRequest[] + + @@map("telegram_links") +} + +/// A queued request to send a package to a Telegram user via the bot. +model BotSendRequest { + id String @id @default(cuid()) + packageId String + telegramLinkId String + requestedByUserId String + status BotSendStatus @default(PENDING) + error String? + createdAt DateTime @default(now()) + completedAt DateTime? + + package Package @relation(fields: [packageId], references: [id]) + telegramLink TelegramLink @relation(fields: [telegramLinkId], references: [id], onDelete: Cascade) + + @@index([status]) + @@index([telegramLinkId]) + @@index([createdAt]) + @@map("bot_send_requests") +} + +/// Subscriptions for new-package notifications via the bot. +model BotSubscription { + id String @id @default(cuid()) + telegramUserId BigInt + pattern String @db.VarChar(256) + createdAt DateTime @default(now()) + + @@unique([telegramUserId, pattern]) + @@index([telegramUserId]) + @@map("bot_subscriptions") +} diff --git a/src/app/(app)/settings/_components/telegram-link-card.tsx b/src/app/(app)/settings/_components/telegram-link-card.tsx new file mode 100644 index 0000000..4aafa60 --- /dev/null +++ b/src/app/(app)/settings/_components/telegram-link-card.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Send, Link2, Unlink, Copy, Loader2, CheckCircle2 } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + generateTelegramLinkCode, + unlinkTelegram, +} from "../telegram-actions"; + +interface TelegramLinkCardProps { + linked: boolean; + telegramName: string | null; + telegramUserId: string | null; + linkedAt: string | null; + botUsername?: string | null; +} + +export function TelegramLinkCard({ + linked: initialLinked, + telegramName: initialName, + telegramUserId: initialUserId, + linkedAt: initialLinkedAt, + botUsername, +}: TelegramLinkCardProps) { + const [isPending, startTransition] = useTransition(); + const [linked, setLinked] = useState(initialLinked); + const [telegramName, setTelegramName] = useState(initialName); + const [telegramUserId, setTelegramUserId] = useState(initialUserId); + const [linkedAt, setLinkedAt] = useState(initialLinkedAt); + const [linkCode, setLinkCode] = useState(null); + const [codeExpiresAt, setCodeExpiresAt] = useState(null); + const [copied, setCopied] = useState(false); + + function handleGenerateCode() { + startTransition(async () => { + const result = await generateTelegramLinkCode(); + if (!result.success) { + toast.error(result.error); + return; + } + setLinkCode(result.data.code); + setCodeExpiresAt(result.data.expiresAt); + toast.success("Link code generated! Send it to the bot within 10 minutes."); + }); + } + + function handleUnlink() { + startTransition(async () => { + const result = await unlinkTelegram(); + if (!result.success) { + toast.error(result.error); + return; + } + setLinked(false); + setTelegramName(null); + setTelegramUserId(null); + setLinkedAt(null); + setLinkCode(null); + toast.success("Telegram account unlinked"); + }); + } + + async function handleCopy() { + if (!linkCode) return; + const command = `/link ${linkCode}`; + await navigator.clipboard.writeText(command); + setCopied(true); + toast.success("Copied to clipboard"); + setTimeout(() => setCopied(false), 2000); + } + + const botLink = botUsername + ? `https://t.me/${botUsername}?start=link_${linkCode}` + : null; + + return ( + + +
+ + Telegram Link + {linked ? ( + + Linked + + ) : ( + + Not linked + + )} +
+ + Link your account to receive packages via the Telegram bot. + +
+ + {linked ? ( + <> +
+
+ Telegram: + + {telegramName ?? `User ${telegramUserId}`} + +
+ {linkedAt && ( +
+ Linked: + {new Date(linkedAt).toLocaleDateString()} +
+ )} +
+ + + ) : ( + <> + {linkCode ? ( +
+
+

+ Send this command to the bot: +

+
+ + /link {linkCode} + + +
+ {codeExpiresAt && ( +

+ Expires:{" "} + {new Date(codeExpiresAt).toLocaleTimeString()} +

+ )} +
+ {botLink && ( + + + Or click here to open the bot directly + + )} + +
+ ) : ( + + )} + + )} +
+
+ ); +} diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index 6010544..01fdaf4 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -3,12 +3,19 @@ import { redirect } from "next/navigation"; import { getUserSettings } from "@/data/settings.queries"; import { PageHeader } from "@/components/shared/page-header"; import { SettingsForm } from "./_components/settings-form"; +import { TelegramLinkCard } from "./_components/telegram-link-card"; +import { prisma } from "@/lib/prisma"; export default async function SettingsPage() { const session = await auth(); if (!session?.user?.id) redirect("/login"); - const settings = await getUserSettings(session.user.id); + const [settings, telegramLink] = await Promise.all([ + getUserSettings(session.user.id), + prisma.telegramLink.findUnique({ + where: { userId: session.user.id }, + }), + ]); return (
@@ -16,10 +23,17 @@ export default async function SettingsPage() { title="Settings" description="Manage your application preferences" /> -
+
+
); diff --git a/src/app/(app)/settings/telegram-actions.ts b/src/app/(app)/settings/telegram-actions.ts new file mode 100644 index 0000000..3fc3e33 --- /dev/null +++ b/src/app/(app)/settings/telegram-actions.ts @@ -0,0 +1,171 @@ +"use server"; + +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { revalidatePath } from "next/cache"; +import type { ActionResult } from "@/types/api.types"; +import { randomBytes } from "crypto"; + +/** + * Generate a one-time link code for the current user. + * The user sends `/link ` to the bot to complete the link. + * Code is stored in GlobalSetting as `link_code:` → userId. + * Codes expire after 10 minutes (checked by the bot). + */ +export async function generateTelegramLinkCode(): Promise< + ActionResult<{ code: string; expiresAt: string }> +> { + const session = await auth(); + if (!session?.user?.id) return { success: false, error: "Unauthorized" }; + + // Check if user already has a link + const existing = await prisma.telegramLink.findUnique({ + where: { userId: session.user.id }, + }); + if (existing) { + return { + success: false, + error: "You already have a linked Telegram account. Unlink first to generate a new code.", + }; + } + + // Generate a short random code + const code = randomBytes(4).toString("hex"); // 8 hex chars + const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes + + // Store in GlobalSetting — overwrites any previous code for this user + // First, clean up any previous codes for this user + const existingCodes = await prisma.globalSetting.findMany({ + where: { key: { startsWith: "link_code:" } }, + }); + for (const setting of existingCodes) { + try { + const parsed = JSON.parse(setting.value); + if (parsed.userId === session.user.id) { + await prisma.globalSetting.delete({ where: { key: setting.key } }); + } + } catch { + // Skip malformed entries + } + } + + // Store the new code + await prisma.globalSetting.upsert({ + where: { key: `link_code:${code}` }, + update: { + value: JSON.stringify({ + userId: session.user.id, + expiresAt: expiresAt.toISOString(), + }), + }, + create: { + key: `link_code:${code}`, + value: JSON.stringify({ + userId: session.user.id, + expiresAt: expiresAt.toISOString(), + }), + }, + }); + + return { + success: true, + data: { code, expiresAt: expiresAt.toISOString() }, + }; +} + +/** + * Get the current user's Telegram link status. + */ +export async function getTelegramLinkStatus(): Promise< + ActionResult<{ + linked: boolean; + telegramName: string | null; + telegramUserId: string | null; + linkedAt: string | null; + }> +> { + const session = await auth(); + if (!session?.user?.id) return { success: false, error: "Unauthorized" }; + + const link = await prisma.telegramLink.findUnique({ + where: { userId: session.user.id }, + }); + + return { + success: true, + data: { + linked: !!link, + telegramName: link?.telegramName ?? null, + telegramUserId: link?.telegramUserId?.toString() ?? null, + linkedAt: link?.createdAt?.toISOString() ?? null, + }, + }; +} + +/** + * Unlink the current user's Telegram account. + */ +export async function unlinkTelegram(): Promise { + const session = await auth(); + if (!session?.user?.id) return { success: false, error: "Unauthorized" }; + + const link = await prisma.telegramLink.findUnique({ + where: { userId: session.user.id }, + }); + + if (!link) { + return { success: false, error: "No linked Telegram account found" }; + } + + await prisma.telegramLink.delete({ where: { id: link.id } }); + + revalidatePath("/settings"); + return { success: true, data: undefined }; +} + +/** + * Get recent bot send requests for the current user (or all for admins). + */ +export async function getBotSendHistory( + limit = 20 +): Promise< + ActionResult< + Array<{ + id: string; + packageName: string; + recipientName: string | null; + status: string; + error: string | null; + createdAt: string; + completedAt: string | null; + }> + > +> { + const session = await auth(); + if (!session?.user?.id) return { success: false, error: "Unauthorized" }; + + const isAdmin = session.user.role === "ADMIN"; + + const requests = await prisma.botSendRequest.findMany({ + where: isAdmin ? {} : { requestedByUserId: session.user.id }, + orderBy: { createdAt: "desc" }, + take: limit, + include: { + package: { select: { fileName: true } }, + telegramLink: { select: { telegramName: true } }, + }, + }); + + return { + success: true, + data: requests.map((r: typeof requests[number]) => ({ + id: r.id, + packageName: r.package.fileName, + recipientName: r.telegramLink.telegramName, + status: r.status, + error: r.error, + createdAt: r.createdAt.toISOString(), + completedAt: r.completedAt?.toISOString() ?? null, + })), + }; +} diff --git a/src/app/(app)/stls/_components/package-columns.tsx b/src/app/(app)/stls/_components/package-columns.tsx index c75804f..201d0b2 100644 --- a/src/app/(app)/stls/_components/package-columns.tsx +++ b/src/app/(app)/stls/_components/package-columns.tsx @@ -5,6 +5,7 @@ import { FileArchive, Eye, ImageIcon } from "lucide-react"; import { DataTableColumnHeader } from "@/components/shared/data-table-column-header"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { SendToTelegramButton } from "./send-to-telegram-button"; export interface PackageRow { id: string; @@ -139,14 +140,21 @@ export function getPackageColumns({ { id: "actions", cell: ({ row }) => ( - +
+ + +
), enableHiding: false, }, diff --git a/src/app/(app)/stls/_components/package-files-drawer.tsx b/src/app/(app)/stls/_components/package-files-drawer.tsx index ab08264..e2be963 100644 --- a/src/app/(app)/stls/_components/package-files-drawer.tsx +++ b/src/app/(app)/stls/_components/package-files-drawer.tsx @@ -23,6 +23,7 @@ import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import type { PackageRow } from "./package-columns"; +import { SendToTelegramButton } from "./send-to-telegram-button"; interface FileItem { id: string; @@ -306,6 +307,14 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange }: PackageFilesDraw {total.toLocaleString()} file{total !== 1 ? "s" : ""} in archive + {pkg && ( +
+ +
+ )}
diff --git a/src/app/(app)/stls/_components/send-to-telegram-button.tsx b/src/app/(app)/stls/_components/send-to-telegram-button.tsx new file mode 100644 index 0000000..f096afb --- /dev/null +++ b/src/app/(app)/stls/_components/send-to-telegram-button.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { useState, useTransition, useEffect, useRef } from "react"; +import { Send, Loader2, CheckCircle2, AlertCircle } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; + +interface SendToTelegramButtonProps { + packageId: string; + packageName: string; + /** variant for inline row actions vs larger button */ + variant?: "icon" | "default"; +} + +type SendStatus = "idle" | "sending" | "polling" | "sent" | "failed"; + +export function SendToTelegramButton({ + packageId, + packageName, + variant = "default", +}: SendToTelegramButtonProps) { + const [open, setOpen] = useState(false); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + const pollRef = useRef | null>(null); + + // Clean up polling on unmount + useEffect(() => { + return () => { + if (pollRef.current) clearInterval(pollRef.current); + }; + }, []); + + function handleSend() { + startTransition(async () => { + setStatus("sending"); + setError(null); + + try { + const res = await fetch("/api/telegram/bot/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ packageId }), + }); + + const data = await res.json(); + + if (!res.ok) { + setStatus("failed"); + setError(data.error ?? "Failed to queue send"); + return; + } + + // Start polling for status + setStatus("polling"); + const requestId = data.requestId; + + pollRef.current = setInterval(async () => { + try { + const statusRes = await fetch(`/api/telegram/bot/send/${requestId}`); + const statusData = await statusRes.json(); + + if (statusData.status === "SENT") { + setStatus("sent"); + toast.success(`"${packageName}" sent to Telegram`); + if (pollRef.current) clearInterval(pollRef.current); + } else if (statusData.status === "FAILED") { + setStatus("failed"); + setError(statusData.error ?? "Send failed"); + if (pollRef.current) clearInterval(pollRef.current); + } + // PENDING / SENDING — keep polling + } catch { + // Network error — keep trying + } + }, 2000); + + // Stop polling after 60 seconds + setTimeout(() => { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + setStatus((s: SendStatus) => { + if (s === "polling") return "sent"; // Assume queued successfully + return s; + }); + }, 60000); + } catch { + setStatus("failed"); + setError("Network error"); + } + }); + } + + function handleClose() { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + setOpen(false); + // Reset after animation + setTimeout(() => { + setStatus("idle"); + setError(null); + }, 200); + } + + const trigger = + variant === "icon" ? ( + + ) : ( + + ); + + return ( + (o ? setOpen(true) : handleClose())}> + {trigger} + + + Send to Telegram + + Send “{packageName}” to your linked Telegram account. + + + +
+ {status === "idle" && ( +

+ The bot will forward the archive files from the destination channel + to your linked Telegram account. +

+ )} + + {(status === "sending" || status === "polling") && ( +
+ +
+

+ {status === "sending" ? "Queuing…" : "Sending…"} +

+

+ The bot is forwarding the files to your Telegram. +

+
+
+ )} + + {status === "sent" && ( +
+ +
+

Sent!

+

+ Check your Telegram messages. +

+
+
+ )} + + {status === "failed" && ( +
+ +
+

Failed

+

{error}

+
+
+ )} +
+ + + {status === "idle" && ( + <> + + + + )} + + {(status === "sent" || status === "failed") && ( + + )} + +
+
+ ); +} diff --git a/src/app/(app)/telegram/_components/bot-sends-tab.tsx b/src/app/(app)/telegram/_components/bot-sends-tab.tsx new file mode 100644 index 0000000..0a8e44f --- /dev/null +++ b/src/app/(app)/telegram/_components/bot-sends-tab.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Send } from "lucide-react"; +import type { SendHistoryRow } from "@/types/telegram.types"; + +interface BotSendsTabProps { + history: SendHistoryRow[]; +} + +function statusBadge(status: string) { + switch (status) { + case "SENT": + return Sent; + case "SENDING": + return Sending; + case "PENDING": + return Pending; + case "FAILED": + return Failed; + default: + return {status}; + } +} + +export function BotSendsTab({ history }: BotSendsTabProps) { + return ( + + +
+ + Bot Send History +
+ + Recent package deliveries via the Telegram bot. + +
+ + {history.length === 0 ? ( +
+ + No sends yet. Use the “Send to Telegram” button on a + package to get started. +
+ ) : ( +
+ + + + Package + Recipient + Status + Requested + Completed + + + + {history.map((row) => ( + + + {row.packageName} + + + {row.recipientName ?? "—"} + + +
+ {statusBadge(row.status)} + {row.error && ( + + {row.error} + + )} +
+
+ + {new Date(row.createdAt).toLocaleString()} + + + {row.completedAt + ? new Date(row.completedAt).toLocaleString() + : "—"} + +
+ ))} +
+
+
+ )} +
+
+ ); +} diff --git a/src/app/(app)/telegram/_components/telegram-admin.tsx b/src/app/(app)/telegram/_components/telegram-admin.tsx index 5312dc0..d83e239 100644 --- a/src/app/(app)/telegram/_components/telegram-admin.tsx +++ b/src/app/(app)/telegram/_components/telegram-admin.tsx @@ -5,14 +5,17 @@ import { PageHeader } from "@/components/shared/page-header"; import { AccountsTab } from "./accounts-tab"; import { ChannelsTab } from "./channels-tab"; import { WorkerStatusPanel } from "./worker-status-panel"; +import { BotSendsTab } from "./bot-sends-tab"; import type { AccountRow, ChannelRow, GlobalDestination } from "@/lib/telegram/admin-queries"; import type { IngestionAccountStatus } from "@/lib/telegram/types"; +import type { SendHistoryRow } from "@/types/telegram.types"; interface TelegramAdminProps { accounts: AccountRow[]; channels: ChannelRow[]; ingestionStatus: IngestionAccountStatus[]; globalDestination: GlobalDestination; + sendHistory: SendHistoryRow[]; } export function TelegramAdmin({ @@ -20,6 +23,7 @@ export function TelegramAdmin({ channels, ingestionStatus, globalDestination, + sendHistory, }: TelegramAdminProps) { return (
@@ -38,6 +42,9 @@ export function TelegramAdmin({ Channels ({channels.length}) + + Bot Sends ({sendHistory.length}) + @@ -46,6 +53,9 @@ export function TelegramAdmin({ + + +
); diff --git a/src/app/(app)/telegram/page.tsx b/src/app/(app)/telegram/page.tsx index de35a0d..a0f575d 100644 --- a/src/app/(app)/telegram/page.tsx +++ b/src/app/(app)/telegram/page.tsx @@ -2,6 +2,7 @@ import { auth } from "@/lib/auth"; import { redirect } from "next/navigation"; import { listAccounts, listChannels, getGlobalDestination } from "@/lib/telegram/admin-queries"; import { getIngestionStatus } from "@/lib/telegram/queries"; +import { prisma } from "@/lib/prisma"; import { TelegramAdmin } from "./_components/telegram-admin"; export default async function TelegramPage() { @@ -9,19 +10,38 @@ export default async function TelegramPage() { if (!session?.user?.id) redirect("/login"); if (session.user.role !== "ADMIN") redirect("/dashboard"); - const [accounts, channels, ingestionStatus, globalDestination] = await Promise.all([ + const [accounts, channels, ingestionStatus, globalDestination, sendHistory] = await Promise.all([ listAccounts(), listChannels(), getIngestionStatus(), getGlobalDestination(), + prisma.botSendRequest.findMany({ + orderBy: { createdAt: "desc" }, + take: 50, + include: { + package: { select: { fileName: true } }, + telegramLink: { select: { telegramName: true } }, + }, + }), ]); + const serializedHistory = sendHistory.map((r) => ({ + id: r.id, + packageName: r.package.fileName, + recipientName: r.telegramLink.telegramName, + status: r.status, + error: r.error, + createdAt: r.createdAt.toISOString(), + completedAt: r.completedAt?.toISOString() ?? null, + })); + return ( ); } diff --git a/src/app/api/telegram/bot/send/[id]/route.ts b/src/app/api/telegram/bot/send/[id]/route.ts new file mode 100644 index 0000000..0324ca3 --- /dev/null +++ b/src/app/api/telegram/bot/send/[id]/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/telegram/bot/send/[id] + * Poll the status of a bot send request. + */ +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + const sendRequest = await prisma.botSendRequest.findUnique({ + where: { id }, + select: { + id: true, + status: true, + error: true, + requestedByUserId: true, + createdAt: true, + completedAt: true, + package: { select: { id: true, fileName: true } }, + telegramLink: { select: { userId: true } }, + }, + }); + + if (!sendRequest) { + return NextResponse.json({ error: "Send request not found" }, { status: 404 }); + } + + // Users can only see their own requests unless admin + const isOwner = + sendRequest.requestedByUserId === session.user.id || + sendRequest.telegramLink.userId === session.user.id; + + if (!isOwner && session.user.role !== "ADMIN") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + return NextResponse.json({ + id: sendRequest.id, + status: sendRequest.status, + error: sendRequest.error, + packageId: sendRequest.package.id, + fileName: sendRequest.package.fileName, + createdAt: sendRequest.createdAt, + completedAt: sendRequest.completedAt, + }); +} diff --git a/src/app/api/telegram/bot/send/route.ts b/src/app/api/telegram/bot/send/route.ts new file mode 100644 index 0000000..291976e --- /dev/null +++ b/src/app/api/telegram/bot/send/route.ts @@ -0,0 +1,98 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +/** + * POST /api/telegram/bot/send + * Queue a package to be sent to a user's linked Telegram account via the bot. + * + * Body: { packageId: string, targetUserId?: string } + * - targetUserId: optional, admin-only — send to another user's linked TG + */ +export async function POST(request: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: { packageId?: string; targetUserId?: string }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + if (!body.packageId) { + return NextResponse.json({ error: "packageId is required" }, { status: 400 }); + } + + // Determine whose TelegramLink to use + const targetUserId = body.targetUserId ?? session.user.id; + + // Only admins can send to other users + if (body.targetUserId && body.targetUserId !== session.user.id) { + if (session.user.role !== "ADMIN") { + return NextResponse.json( + { error: "Only admins can send to other users" }, + { status: 403 } + ); + } + } + + // Verify the target user has a linked Telegram account + const telegramLink = await prisma.telegramLink.findUnique({ + where: { userId: targetUserId }, + }); + + if (!telegramLink) { + return NextResponse.json( + { error: "Target user has no linked Telegram account. Link one in Settings → Telegram." }, + { status: 400 } + ); + } + + // Verify the package exists and has a destination message + const pkg = await prisma.package.findUnique({ + where: { id: body.packageId }, + select: { id: true, fileName: true, destChannelId: true, destMessageId: true }, + }); + + if (!pkg) { + return NextResponse.json({ error: "Package not found" }, { status: 404 }); + } + + if (!pkg.destChannelId || !pkg.destMessageId) { + return NextResponse.json( + { error: "Package has not been uploaded to a destination channel yet" }, + { status: 400 } + ); + } + + // Create the send request + const sendRequest = await prisma.botSendRequest.create({ + data: { + packageId: body.packageId, + telegramLinkId: telegramLink.id, + requestedByUserId: session.user.id, + status: "PENDING", + }, + }); + + // Notify the bot via pg_notify + try { + await prisma.$queryRawUnsafe( + `SELECT pg_notify('bot_send', $1)`, + sendRequest.id + ); + } catch { + // Best-effort — the bot also polls periodically + } + + return NextResponse.json({ + requestId: sendRequest.id, + status: "PENDING", + message: `Queued "${pkg.fileName}" for delivery to Telegram`, + }); +} diff --git a/src/types/telegram.types.ts b/src/types/telegram.types.ts new file mode 100644 index 0000000..f94427f --- /dev/null +++ b/src/types/telegram.types.ts @@ -0,0 +1,9 @@ +export interface SendHistoryRow { + id: string; + packageName: string; + recipientName: string | null; + status: string; + error: string | null; + createdAt: string; + completedAt: string | null; +} diff --git a/worker/src/db/queries.ts b/worker/src/db/queries.ts index ee03803..f42fbcf 100644 --- a/worker/src/db/queries.ts +++ b/worker/src/db/queries.ts @@ -116,7 +116,7 @@ export interface CreatePackageInput { } export async function createPackageWithFiles(input: CreatePackageInput) { - return db.package.create({ + const pkg = await db.package.create({ data: { contentHash: input.contentHash, fileName: input.fileName, @@ -139,6 +139,22 @@ export async function createPackageWithFiles(input: CreatePackageInput) { }, }, }); + + // Notify the bot service about the new package (for subscription alerts) + try { + await db.$queryRawUnsafe( + `SELECT pg_notify('new_package', $1)`, + JSON.stringify({ + packageId: pkg.id, + fileName: input.fileName, + creator: input.creator ?? null, + }) + ); + } catch { + // Best-effort — don't fail the ingestion if notification fails + } + + return pkg; } export async function createIngestionRun(accountId: string) {