diff --git a/.dockerignore b/.dockerignore index 402d386..bb93422 100644 --- a/.dockerignore +++ b/.dockerignore @@ -128,4 +128,10 @@ coverage/ # Build artifacts build/ -dist/ \ No newline at end of file +dist/ + +# Prisma generated files (generated in container) +generated/ + +# Local runtime data (mounted as volumes) +data/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index ed48a78..0086aaa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,11 @@ FROM node:${NODE_VERSION}-alpine AS base # Install pnpm RUN corepack enable && corepack prepare pnpm@10.17.1 --activate +# Disable interactive prompts for pnpm in CI/Docker +ENV CI=true +ENV npm_config_build_from_source=false +ENV PNP_BUILD_FROM_SOURCE=false + WORKDIR /app # Dependencies stage - Install production dependencies only diff --git a/docker-compose.yml b/docker-compose.yml index f55bf56..021092b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: discord-bot: build: @@ -31,6 +29,8 @@ services: - NODE_ENV=development volumes: - .:/app + - /app/node_modules # Exclude node_modules from volume mount + - /app/generated # Exclude generated from volume mount - ./logs:/app/logs - ./data:/app/data ports: diff --git a/package.json b/package.json index a9fef89..d51b3fd 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build:ci": "rm -rf dist && NODE_ENV=production tsc --outDir dist", "start:ci": "NODE_ENV=production node dist/index.js ", "dev": "NODE_ENV=development tsx --watch src/index.ts", + "dev:docker": "docker compose --profile dev up --build", "test": "tsx --test src/**/*.test.ts tests/*.test.ts", "lint": "biome lint .", "lint:fix": "biome lint --fix .", @@ -26,11 +27,15 @@ "dependencies": { "@prisma/adapter-better-sqlite3": "^7.0.0", "@prisma/client": "^7.0.0", - "discord.js": "^14.22.1" + "better-sqlite3": "^11.0.0", + "discord.js": "^14.22.1", + "node-cron": "^4.2.1" }, "devDependencies": { "@biomejs/biome": "^2.2.4", + "@types/better-sqlite3": "^7.6.11", "@types/node": "^24.5.2", + "@types/node-cron": "^3.0.11", "husky": "^9.1.7", "lint-staged": "^16.2.1", "prisma": "^7.0.0", @@ -42,5 +47,10 @@ "biome format --write", "biome lint --fix" ] + }, + "pnpm": { + "onlyBuiltDependencies": [ + "better-sqlite3" + ] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d9c33b..d7e8589 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,84 +13,96 @@ importers: version: 7.0.0 '@prisma/client': specifier: ^7.0.0 - version: 7.0.0(prisma@7.0.0(@types/react@19.2.6)(better-sqlite3@11.10.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.2))(typescript@5.9.2) + version: 7.0.0(prisma@7.0.0(@types/react@19.2.6)(better-sqlite3@11.10.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3) + better-sqlite3: + specifier: ^11.0.0 + version: 11.10.0 discord.js: specifier: ^14.22.1 - version: 14.22.1 + version: 14.25.1 + node-cron: + specifier: ^4.2.1 + version: 4.2.1 devDependencies: '@biomejs/biome': specifier: ^2.2.4 - version: 2.2.4 + version: 2.3.7 + '@types/better-sqlite3': + specifier: ^7.6.11 + version: 7.6.13 '@types/node': specifier: ^24.5.2 - version: 24.5.2 + version: 24.10.1 + '@types/node-cron': + specifier: ^3.0.11 + version: 3.0.11 husky: specifier: ^9.1.7 version: 9.1.7 lint-staged: specifier: ^16.2.1 - version: 16.2.1 + version: 16.2.7 prisma: specifier: ^7.0.0 - version: 7.0.0(@types/react@19.2.6)(better-sqlite3@11.10.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.2) + version: 7.0.0(@types/react@19.2.6)(better-sqlite3@11.10.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3) tsx: specifier: ^4.20.6 version: 4.20.6 typescript: specifier: ^5.9.2 - version: 5.9.2 + version: 5.9.3 packages: - '@biomejs/biome@2.2.4': - resolution: {integrity: sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg==} + '@biomejs/biome@2.3.7': + resolution: {integrity: sha512-CTbAS/jNAiUc6rcq94BrTB8z83O9+BsgWj2sBCQg9rD6Wkh2gjfR87usjx0Ncx0zGXP1NKgT7JNglay5Zfs9jw==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.2.4': - resolution: {integrity: sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA==} + '@biomejs/cli-darwin-arm64@2.3.7': + resolution: {integrity: sha512-LirkamEwzIUULhXcf2D5b+NatXKeqhOwilM+5eRkbrnr6daKz9rsBL0kNZ16Hcy4b8RFq22SG4tcLwM+yx/wFA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.2.4': - resolution: {integrity: sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg==} + '@biomejs/cli-darwin-x64@2.3.7': + resolution: {integrity: sha512-Q4TO633kvrMQkKIV7wmf8HXwF0dhdTD9S458LGE24TYgBjSRbuhvio4D5eOQzirEYg6eqxfs53ga/rbdd8nBKg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.2.4': - resolution: {integrity: sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ==} + '@biomejs/cli-linux-arm64-musl@2.3.7': + resolution: {integrity: sha512-/afy8lto4CB8scWfMdt+NoCZtatBUF62Tk3ilWH2w8ENd5spLhM77zKlFZEvsKJv9AFNHknMl03zO67CiklL2Q==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-arm64@2.2.4': - resolution: {integrity: sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw==} + '@biomejs/cli-linux-arm64@2.3.7': + resolution: {integrity: sha512-inHOTdlstUBzgjDcx0ge71U4SVTbwAljmkfi3MC5WzsYCRhancqfeL+sa4Ke6v2ND53WIwCFD5hGsYExoI3EZQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-x64-musl@2.2.4': - resolution: {integrity: sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg==} + '@biomejs/cli-linux-x64-musl@2.3.7': + resolution: {integrity: sha512-CQUtgH1tIN6e5wiYSJqzSwJumHYolNtaj1dwZGCnZXm2PZU1jOJof9TsyiP3bXNDb+VOR7oo7ZvY01If0W3iFQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-linux-x64@2.2.4': - resolution: {integrity: sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ==} + '@biomejs/cli-linux-x64@2.3.7': + resolution: {integrity: sha512-fJMc3ZEuo/NaMYo5rvoWjdSS5/uVSW+HPRQujucpZqm2ZCq71b8MKJ9U4th9yrv2L5+5NjPF0nqqILCl8HY/fg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-win32-arm64@2.2.4': - resolution: {integrity: sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ==} + '@biomejs/cli-win32-arm64@2.3.7': + resolution: {integrity: sha512-aJAE8eCNyRpcfx2JJAtsPtISnELJ0H4xVVSwnxm13bzI8RwbXMyVtxy2r5DV1xT3WiSP+7LxORcApWw0LM8HiA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.2.4': - resolution: {integrity: sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg==} + '@biomejs/cli-win32-x64@2.3.7': + resolution: {integrity: sha512-pulzUshqv9Ed//MiE8MOUeeEkbkSHVDVY5Cz5wVAnH1DUqliCQG3j6s1POaITTFqFfo7AVIx2sWdKpx/GS+Nqw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -107,8 +119,8 @@ packages: '@chevrotain/utils@10.5.0': resolution: {integrity: sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==} - '@discordjs/builders@1.11.3': - resolution: {integrity: sha512-p3kf5eV49CJiRTfhtutUCeivSyQ/l2JlKodW1ZquRwwvlOWmG9+6jFShX6x8rUiYhnP6wKI96rgN/SXMy5e5aw==} + '@discordjs/builders@1.13.0': + resolution: {integrity: sha512-COK0uU6ZaJI+LA67H/rp8IbEkYwlZf3mAoBI5wtPh5G5cbEQGNhVpzINg2f/6+q/YipnNIKy6fJDg6kMUKUw4Q==} engines: {node: '>=16.11.0'} '@discordjs/collection@1.5.3': @@ -119,16 +131,16 @@ packages: resolution: {integrity: sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==} engines: {node: '>=18'} - '@discordjs/formatters@0.6.1': - resolution: {integrity: sha512-5cnX+tASiPCqCWtFcFslxBVUaCetB0thvM/JyavhbXInP1HJIEU+Qv/zMrnuwSsX3yWH2lVXNJZeDK3EiP4HHg==} + '@discordjs/formatters@0.6.2': + resolution: {integrity: sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==} engines: {node: '>=16.11.0'} '@discordjs/rest@2.6.0': resolution: {integrity: sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==} engines: {node: '>=18'} - '@discordjs/util@1.1.1': - resolution: {integrity: sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==} + '@discordjs/util@1.2.0': + resolution: {integrity: sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==} engines: {node: '>=18'} '@discordjs/ws@1.2.3': @@ -149,158 +161,158 @@ packages: '@electric-sql/pglite@0.3.2': resolution: {integrity: sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==} - '@esbuild/aix-ppc64@0.25.10': - resolution: {integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.25.10': - resolution: {integrity: sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==} + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.25.10': - resolution: {integrity: sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==} + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.25.10': - resolution: {integrity: sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==} + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.25.10': - resolution: {integrity: sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==} + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.25.10': - resolution: {integrity: sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==} + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.25.10': - resolution: {integrity: sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==} + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.10': - resolution: {integrity: sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==} + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.25.10': - resolution: {integrity: sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==} + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.25.10': - resolution: {integrity: sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==} + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.25.10': - resolution: {integrity: sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==} + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.25.10': - resolution: {integrity: sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==} + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.25.10': - resolution: {integrity: sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==} + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.25.10': - resolution: {integrity: sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==} + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.25.10': - resolution: {integrity: sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==} + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.25.10': - resolution: {integrity: sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==} + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.25.10': - resolution: {integrity: sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==} + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.10': - resolution: {integrity: sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==} + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.10': - resolution: {integrity: sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==} + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.10': - resolution: {integrity: sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==} + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.10': - resolution: {integrity: sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==} + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.10': - resolution: {integrity: sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==} + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.25.10': - resolution: {integrity: sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==} + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.25.10': - resolution: {integrity: sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==} + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.25.10': - resolution: {integrity: sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==} + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.10': - resolution: {integrity: sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==} + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -388,8 +400,14 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} - '@types/node@24.5.2': - resolution: {integrity: sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==} + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + + '@types/node-cron@3.0.11': + resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==} + + '@types/node@24.10.1': + resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} '@types/react@19.2.6': resolution: {integrity: sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==} @@ -397,12 +415,12 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@vladfrangu/async_event_emitter@2.4.6': - resolution: {integrity: sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA==} + '@vladfrangu/async_event_emitter@2.4.7': + resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} - ansi-escapes@7.1.1: - resolution: {integrity: sha512-Zhl0ErHcSRUaVfGUeUdDuLgpkEo8KIFjB4Y9uAc46ScOpdDiU1Dbyplh7qWJeJ/ZHpbyMSM26+X3BySgnIz40Q==} + ansi-escapes@7.2.0: + resolution: {integrity: sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==} engines: {node: '>=18'} ansi-regex@6.2.2: @@ -461,15 +479,15 @@ packages: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} - cli-truncate@5.1.0: - resolution: {integrity: sha512-7JDGG+4Zp0CsknDCedl0DYdaeOhc46QNpXi3NLQblkZpXXgA6LncLDUUyvrjSvZeF3VRQa+KiMGomazQrC1V8g==} + cli-truncate@5.1.1: + resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==} engines: {node: '>=20'} colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - commander@14.0.1: - resolution: {integrity: sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==} + commander@14.0.2: + resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} engines: {node: '>=20'} confbox@0.2.2: @@ -512,11 +530,11 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - discord-api-types@0.38.26: - resolution: {integrity: sha512-xpmPviHjIJ6dFu1eNwNDIGQ3N6qmPUUYFVAx/YZ64h7ZgPkTcKjnciD8bZe8Vbeji7yS5uYljyciunpq0J5NSw==} + discord-api-types@0.38.34: + resolution: {integrity: sha512-muq7xKGznj5MSFCzuIm/2TO7DpttuomUTemVM82fRqgnMl70YRzEyY24jlbiV6R9tzOTq6A6UnZ0bsfZeKD38Q==} - discord.js@14.22.1: - resolution: {integrity: sha512-3k+Kisd/v570Jr68A1kNs7qVhNehDwDJAPe4DZ2Syt+/zobf9zEcuYFvsfIaAOgCa0BiHMfOOKQY4eYINl0z7w==} + discord.js@14.25.1: + resolution: {integrity: sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==} engines: {node: '>=18'} dotenv@16.6.1: @@ -526,8 +544,8 @@ packages: effect@3.18.4: resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==} - emoji-regex@10.5.0: - resolution: {integrity: sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} empathic@2.0.0: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} @@ -540,8 +558,8 @@ packages: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} - esbuild@0.25.10: - resolution: {integrity: sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} hasBin: true @@ -591,8 +609,8 @@ packages: get-port-please@3.1.2: resolution: {integrity: sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==} - get-tsconfig@4.10.1: - resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} giget@2.0.0: resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} @@ -654,13 +672,13 @@ packages: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} engines: {node: '>=10'} - lint-staged@16.2.1: - resolution: {integrity: sha512-KMeYmH9wKvHsXdUp+z6w7HN3fHKHXwT1pSTQTYxB9kI6ekK1rlL3kLZEoXZCppRPXFK9PFW/wfQctV7XUqMrPQ==} + lint-staged@16.2.7: + resolution: {integrity: sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==} engines: {node: '>=20.17'} hasBin: true - listr2@9.0.4: - resolution: {integrity: sha512-1wd/kpAdKRLwv7/3OKC8zZ5U8e/fajCfWMxacUvB79S5nLrYGPtUI/8chMQhn3LQjsRVErTb9i1ECAwW0ZIHnQ==} + listr2@9.0.5: + resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} engines: {node: '>=20.0.0'} lodash.snakecase@4.1.1: @@ -713,8 +731,8 @@ packages: resolution: {integrity: sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==} engines: {node: '>=12.0.0'} - nano-spawn@1.0.3: - resolution: {integrity: sha512-jtpsQDetTnvS2Ts1fiRdci5rx0VYws5jGyC+4IYOTnIQ/wwdf6JdomlHBwqC3bJYOvaKu0C2GSZ1A60anrYpaA==} + nano-spawn@2.0.0: + resolution: {integrity: sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==} engines: {node: '>=20.17'} napi-build-utils@2.0.0: @@ -724,6 +742,10 @@ packages: resolution: {integrity: sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==} engines: {node: '>=10'} + node-cron@4.2.1: + resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==} + engines: {node: '>=6.0.0'} + node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} @@ -944,13 +966,13 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} - typescript@5.9.2: - resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true - undici-types@7.12.0: - resolution: {integrity: sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} undici@6.21.3: resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==} @@ -1001,39 +1023,39 @@ packages: snapshots: - '@biomejs/biome@2.2.4': + '@biomejs/biome@2.3.7': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.2.4 - '@biomejs/cli-darwin-x64': 2.2.4 - '@biomejs/cli-linux-arm64': 2.2.4 - '@biomejs/cli-linux-arm64-musl': 2.2.4 - '@biomejs/cli-linux-x64': 2.2.4 - '@biomejs/cli-linux-x64-musl': 2.2.4 - '@biomejs/cli-win32-arm64': 2.2.4 - '@biomejs/cli-win32-x64': 2.2.4 - - '@biomejs/cli-darwin-arm64@2.2.4': + '@biomejs/cli-darwin-arm64': 2.3.7 + '@biomejs/cli-darwin-x64': 2.3.7 + '@biomejs/cli-linux-arm64': 2.3.7 + '@biomejs/cli-linux-arm64-musl': 2.3.7 + '@biomejs/cli-linux-x64': 2.3.7 + '@biomejs/cli-linux-x64-musl': 2.3.7 + '@biomejs/cli-win32-arm64': 2.3.7 + '@biomejs/cli-win32-x64': 2.3.7 + + '@biomejs/cli-darwin-arm64@2.3.7': optional: true - '@biomejs/cli-darwin-x64@2.2.4': + '@biomejs/cli-darwin-x64@2.3.7': optional: true - '@biomejs/cli-linux-arm64-musl@2.2.4': + '@biomejs/cli-linux-arm64-musl@2.3.7': optional: true - '@biomejs/cli-linux-arm64@2.2.4': + '@biomejs/cli-linux-arm64@2.3.7': optional: true - '@biomejs/cli-linux-x64-musl@2.2.4': + '@biomejs/cli-linux-x64-musl@2.3.7': optional: true - '@biomejs/cli-linux-x64@2.2.4': + '@biomejs/cli-linux-x64@2.3.7': optional: true - '@biomejs/cli-win32-arm64@2.2.4': + '@biomejs/cli-win32-arm64@2.3.7': optional: true - '@biomejs/cli-win32-x64@2.2.4': + '@biomejs/cli-win32-x64@2.3.7': optional: true '@chevrotain/cst-dts-gen@10.5.0': @@ -1051,12 +1073,12 @@ snapshots: '@chevrotain/utils@10.5.0': {} - '@discordjs/builders@1.11.3': + '@discordjs/builders@1.13.0': dependencies: - '@discordjs/formatters': 0.6.1 - '@discordjs/util': 1.1.1 + '@discordjs/formatters': 0.6.2 + '@discordjs/util': 1.2.0 '@sapphire/shapeshift': 4.0.0 - discord-api-types: 0.38.26 + discord-api-types: 0.38.34 fast-deep-equal: 3.1.3 ts-mixer: 6.0.4 tslib: 2.8.1 @@ -1065,33 +1087,35 @@ snapshots: '@discordjs/collection@2.1.1': {} - '@discordjs/formatters@0.6.1': + '@discordjs/formatters@0.6.2': dependencies: - discord-api-types: 0.38.26 + discord-api-types: 0.38.34 '@discordjs/rest@2.6.0': dependencies: '@discordjs/collection': 2.1.1 - '@discordjs/util': 1.1.1 + '@discordjs/util': 1.2.0 '@sapphire/async-queue': 1.5.5 '@sapphire/snowflake': 3.5.3 - '@vladfrangu/async_event_emitter': 2.4.6 - discord-api-types: 0.38.26 + '@vladfrangu/async_event_emitter': 2.4.7 + discord-api-types: 0.38.34 magic-bytes.js: 1.12.1 tslib: 2.8.1 undici: 6.21.3 - '@discordjs/util@1.1.1': {} + '@discordjs/util@1.2.0': + dependencies: + discord-api-types: 0.38.34 '@discordjs/ws@1.2.3': dependencies: '@discordjs/collection': 2.1.1 '@discordjs/rest': 2.6.0 - '@discordjs/util': 1.1.1 + '@discordjs/util': 1.2.0 '@sapphire/async-queue': 1.5.5 '@types/ws': 8.18.1 - '@vladfrangu/async_event_emitter': 2.4.6 - discord-api-types: 0.38.26 + '@vladfrangu/async_event_emitter': 2.4.7 + discord-api-types: 0.38.34 tslib: 2.8.1 ws: 8.18.3 transitivePeerDependencies: @@ -1108,82 +1132,82 @@ snapshots: '@electric-sql/pglite@0.3.2': {} - '@esbuild/aix-ppc64@0.25.10': + '@esbuild/aix-ppc64@0.25.12': optional: true - '@esbuild/android-arm64@0.25.10': + '@esbuild/android-arm64@0.25.12': optional: true - '@esbuild/android-arm@0.25.10': + '@esbuild/android-arm@0.25.12': optional: true - '@esbuild/android-x64@0.25.10': + '@esbuild/android-x64@0.25.12': optional: true - '@esbuild/darwin-arm64@0.25.10': + '@esbuild/darwin-arm64@0.25.12': optional: true - '@esbuild/darwin-x64@0.25.10': + '@esbuild/darwin-x64@0.25.12': optional: true - '@esbuild/freebsd-arm64@0.25.10': + '@esbuild/freebsd-arm64@0.25.12': optional: true - '@esbuild/freebsd-x64@0.25.10': + '@esbuild/freebsd-x64@0.25.12': optional: true - '@esbuild/linux-arm64@0.25.10': + '@esbuild/linux-arm64@0.25.12': optional: true - '@esbuild/linux-arm@0.25.10': + '@esbuild/linux-arm@0.25.12': optional: true - '@esbuild/linux-ia32@0.25.10': + '@esbuild/linux-ia32@0.25.12': optional: true - '@esbuild/linux-loong64@0.25.10': + '@esbuild/linux-loong64@0.25.12': optional: true - '@esbuild/linux-mips64el@0.25.10': + '@esbuild/linux-mips64el@0.25.12': optional: true - '@esbuild/linux-ppc64@0.25.10': + '@esbuild/linux-ppc64@0.25.12': optional: true - '@esbuild/linux-riscv64@0.25.10': + '@esbuild/linux-riscv64@0.25.12': optional: true - '@esbuild/linux-s390x@0.25.10': + '@esbuild/linux-s390x@0.25.12': optional: true - '@esbuild/linux-x64@0.25.10': + '@esbuild/linux-x64@0.25.12': optional: true - '@esbuild/netbsd-arm64@0.25.10': + '@esbuild/netbsd-arm64@0.25.12': optional: true - '@esbuild/netbsd-x64@0.25.10': + '@esbuild/netbsd-x64@0.25.12': optional: true - '@esbuild/openbsd-arm64@0.25.10': + '@esbuild/openbsd-arm64@0.25.12': optional: true - '@esbuild/openbsd-x64@0.25.10': + '@esbuild/openbsd-x64@0.25.12': optional: true - '@esbuild/openharmony-arm64@0.25.10': + '@esbuild/openharmony-arm64@0.25.12': optional: true - '@esbuild/sunos-x64@0.25.10': + '@esbuild/sunos-x64@0.25.12': optional: true - '@esbuild/win32-arm64@0.25.10': + '@esbuild/win32-arm64@0.25.12': optional: true - '@esbuild/win32-ia32@0.25.10': + '@esbuild/win32-ia32@0.25.12': optional: true - '@esbuild/win32-x64@0.25.10': + '@esbuild/win32-x64@0.25.12': optional: true '@hono/node-server@1.14.2(hono@4.7.10)': @@ -1202,12 +1226,12 @@ snapshots: '@prisma/client-runtime-utils@7.0.0': {} - '@prisma/client@7.0.0(prisma@7.0.0(@types/react@19.2.6)(better-sqlite3@11.10.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.2))(typescript@5.9.2)': + '@prisma/client@7.0.0(prisma@7.0.0(@types/react@19.2.6)(better-sqlite3@11.10.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3)': dependencies: '@prisma/client-runtime-utils': 7.0.0 optionalDependencies: - prisma: 7.0.0(@types/react@19.2.6)(better-sqlite3@11.10.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.2) - typescript: 5.9.2 + prisma: 7.0.0(@types/react@19.2.6)(better-sqlite3@11.10.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + typescript: 5.9.3 '@prisma/config@7.0.0': dependencies: @@ -1222,7 +1246,7 @@ snapshots: '@prisma/debug@7.0.0': {} - '@prisma/dev@0.13.0(typescript@5.9.2)': + '@prisma/dev@0.13.0(typescript@5.9.3)': dependencies: '@electric-sql/pglite': 0.3.2 '@electric-sql/pglite-socket': 0.0.6(@electric-sql/pglite@0.3.2) @@ -1239,7 +1263,7 @@ snapshots: proper-lockfile: 4.1.2 remeda: 2.21.3 std-env: 3.9.0 - valibot: 1.1.0(typescript@5.9.2) + valibot: 1.1.0(typescript@5.9.3) zeptomatch: 2.0.2 transitivePeerDependencies: - typescript @@ -1290,9 +1314,15 @@ snapshots: '@standard-schema/spec@1.0.0': {} - '@types/node@24.5.2': + '@types/better-sqlite3@7.6.13': dependencies: - undici-types: 7.12.0 + '@types/node': 24.10.1 + + '@types/node-cron@3.0.11': {} + + '@types/node@24.10.1': + dependencies: + undici-types: 7.16.0 '@types/react@19.2.6': dependencies: @@ -1300,11 +1330,11 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 24.5.2 + '@types/node': 24.10.1 - '@vladfrangu/async_event_emitter@2.4.6': {} + '@vladfrangu/async_event_emitter@2.4.7': {} - ansi-escapes@7.1.1: + ansi-escapes@7.2.0: dependencies: environment: 1.1.0 @@ -1378,14 +1408,14 @@ snapshots: dependencies: restore-cursor: 5.1.0 - cli-truncate@5.1.0: + cli-truncate@5.1.1: dependencies: slice-ansi: 7.1.2 string-width: 8.1.0 colorette@2.0.20: {} - commander@14.0.1: {} + commander@14.0.2: {} confbox@0.2.2: {} @@ -1415,18 +1445,18 @@ snapshots: detect-libc@2.1.2: {} - discord-api-types@0.38.26: {} + discord-api-types@0.38.34: {} - discord.js@14.22.1: + discord.js@14.25.1: dependencies: - '@discordjs/builders': 1.11.3 + '@discordjs/builders': 1.13.0 '@discordjs/collection': 1.5.3 - '@discordjs/formatters': 0.6.1 + '@discordjs/formatters': 0.6.2 '@discordjs/rest': 2.6.0 - '@discordjs/util': 1.1.1 + '@discordjs/util': 1.2.0 '@discordjs/ws': 1.2.3 '@sapphire/snowflake': 3.5.3 - discord-api-types: 0.38.26 + discord-api-types: 0.38.34 fast-deep-equal: 3.1.3 lodash.snakecase: 4.1.1 magic-bytes.js: 1.12.1 @@ -1443,7 +1473,7 @@ snapshots: '@standard-schema/spec': 1.0.0 fast-check: 3.23.2 - emoji-regex@10.5.0: {} + emoji-regex@10.6.0: {} empathic@2.0.0: {} @@ -1453,34 +1483,34 @@ snapshots: environment@1.1.0: {} - esbuild@0.25.10: + esbuild@0.25.12: optionalDependencies: - '@esbuild/aix-ppc64': 0.25.10 - '@esbuild/android-arm': 0.25.10 - '@esbuild/android-arm64': 0.25.10 - '@esbuild/android-x64': 0.25.10 - '@esbuild/darwin-arm64': 0.25.10 - '@esbuild/darwin-x64': 0.25.10 - '@esbuild/freebsd-arm64': 0.25.10 - '@esbuild/freebsd-x64': 0.25.10 - '@esbuild/linux-arm': 0.25.10 - '@esbuild/linux-arm64': 0.25.10 - '@esbuild/linux-ia32': 0.25.10 - '@esbuild/linux-loong64': 0.25.10 - '@esbuild/linux-mips64el': 0.25.10 - '@esbuild/linux-ppc64': 0.25.10 - '@esbuild/linux-riscv64': 0.25.10 - '@esbuild/linux-s390x': 0.25.10 - '@esbuild/linux-x64': 0.25.10 - '@esbuild/netbsd-arm64': 0.25.10 - '@esbuild/netbsd-x64': 0.25.10 - '@esbuild/openbsd-arm64': 0.25.10 - '@esbuild/openbsd-x64': 0.25.10 - '@esbuild/openharmony-arm64': 0.25.10 - '@esbuild/sunos-x64': 0.25.10 - '@esbuild/win32-arm64': 0.25.10 - '@esbuild/win32-ia32': 0.25.10 - '@esbuild/win32-x64': 0.25.10 + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 eventemitter3@5.0.1: {} @@ -1518,7 +1548,7 @@ snapshots: get-port-please@3.1.2: {} - get-tsconfig@4.10.1: + get-tsconfig@4.13.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -1567,19 +1597,19 @@ snapshots: lilconfig@2.1.0: {} - lint-staged@16.2.1: + lint-staged@16.2.7: dependencies: - commander: 14.0.1 - listr2: 9.0.4 + commander: 14.0.2 + listr2: 9.0.5 micromatch: 4.0.8 - nano-spawn: 1.0.3 + nano-spawn: 2.0.0 pidtree: 0.6.0 string-argv: 0.3.2 yaml: 2.8.1 - listr2@9.0.4: + listr2@9.0.5: dependencies: - cli-truncate: 5.1.0 + cli-truncate: 5.1.1 colorette: 2.0.20 eventemitter3: 5.0.1 log-update: 6.1.0 @@ -1592,7 +1622,7 @@ snapshots: log-update@6.1.0: dependencies: - ansi-escapes: 7.1.1 + ansi-escapes: 7.2.0 cli-cursor: 5.0.0 slice-ansi: 7.1.2 strip-ansi: 7.1.2 @@ -1635,7 +1665,7 @@ snapshots: dependencies: lru-cache: 7.18.3 - nano-spawn@1.0.3: {} + nano-spawn@2.0.0: {} napi-build-utils@2.0.0: {} @@ -1643,6 +1673,8 @@ snapshots: dependencies: semver: 7.7.3 + node-cron@4.2.1: {} + node-fetch-native@1.6.7: {} nypm@0.6.2: @@ -1696,17 +1728,17 @@ snapshots: tar-fs: 2.1.4 tunnel-agent: 0.6.0 - prisma@7.0.0(@types/react@19.2.6)(better-sqlite3@11.10.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.2): + prisma@7.0.0(@types/react@19.2.6)(better-sqlite3@11.10.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3): dependencies: '@prisma/config': 7.0.0 - '@prisma/dev': 0.13.0(typescript@5.9.2) + '@prisma/dev': 0.13.0(typescript@5.9.3) '@prisma/engines': 7.0.0 '@prisma/studio-core-licensed': 0.8.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) mysql2: 3.15.3 postgres: 3.4.7 optionalDependencies: better-sqlite3: 11.10.0 - typescript: 5.9.2 + typescript: 5.9.3 transitivePeerDependencies: - '@types/react' - magicast @@ -1811,7 +1843,7 @@ snapshots: string-width@7.2.0: dependencies: - emoji-regex: 10.5.0 + emoji-regex: 10.6.0 get-east-asian-width: 1.4.0 strip-ansi: 7.1.2 @@ -1857,8 +1889,8 @@ snapshots: tsx@4.20.6: dependencies: - esbuild: 0.25.10 - get-tsconfig: 4.10.1 + esbuild: 0.25.12 + get-tsconfig: 4.13.0 optionalDependencies: fsevents: 2.3.3 @@ -1868,17 +1900,17 @@ snapshots: type-fest@4.41.0: {} - typescript@5.9.2: {} + typescript@5.9.3: {} - undici-types@7.12.0: {} + undici-types@7.16.0: {} undici@6.21.3: {} util-deprecate@1.0.2: {} - valibot@1.1.0(typescript@5.9.2): + valibot@1.1.0(typescript@5.9.3): optionalDependencies: - typescript: 5.9.2 + typescript: 5.9.3 which@2.0.2: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..443e6e7 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +onlyBuiltDependencies: +- better-sqlite3 \ No newline at end of file diff --git a/prisma.config.ts b/prisma.config.ts index e33aad4..756fbda 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -5,8 +5,7 @@ export default defineConfig({ migrations: { path: "prisma/migrations", }, - engine: "classic", datasource: { - url: "file:../data/moderation.db", // Match schema.prisma + url: "file:./data/moderation.db", // Relative to /app }, }); diff --git a/prisma/migrations/20251212111819/migration.sql b/prisma/migrations/20251212111819/migration.sql new file mode 100644 index 0000000..b52a191 --- /dev/null +++ b/prisma/migrations/20251212111819/migration.sql @@ -0,0 +1,38 @@ +/* + Warnings: + + - The primary key for the `User` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `userId` on the `User` table. All the data in the column will be lost. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Action" ( + "actionId" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "userId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'ACTIVE', + "moderatorUserId" TEXT NOT NULL, + "reason" TEXT NOT NULL, + "note" TEXT, + "createdAt" INTEGER NOT NULL, + "expiresAt" INTEGER, + "revertingActionId" INTEGER, + CONSTRAINT "Action_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("discordUserId") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "Action_moderatorUserId_fkey" FOREIGN KEY ("moderatorUserId") REFERENCES "User" ("discordUserId") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "Action_revertingActionId_fkey" FOREIGN KEY ("revertingActionId") REFERENCES "Action" ("actionId") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_Action" ("actionId", "createdAt", "expiresAt", "moderatorUserId", "note", "reason", "revertingActionId", "status", "type", "userId") SELECT "actionId", "createdAt", "expiresAt", "moderatorUserId", "note", "reason", "revertingActionId", "status", "type", "userId" FROM "Action"; +DROP TABLE "Action"; +ALTER TABLE "new_Action" RENAME TO "Action"; +CREATE UNIQUE INDEX "Action_revertingActionId_key" ON "Action"("revertingActionId"); +CREATE TABLE "new_User" ( + "discordUserId" TEXT NOT NULL PRIMARY KEY, + "role" TEXT NOT NULL DEFAULT 'MEMBER' +); +INSERT INTO "new_User" ("discordUserId", "role") SELECT "discordUserId", "role" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3ed7570..f8a221f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4,6 +4,7 @@ generator client { provider = "prisma-client-js" output = "../generated/prisma" + binaryTargets = ["native", "linux-musl-openssl-3.0.x"] } datasource db { @@ -54,8 +55,7 @@ enum ActionReason { } model User { - userId Int @id @default(autoincrement()) - discordUserId String @unique + discordUserId String @id role Role @default(MEMBER) // A user can have many actions performed on them @@ -67,10 +67,10 @@ model User { model Action { actionId Int @id @default(autoincrement()) - userId Int + userId String type ActionType status ActionStatus @default(ACTIVE) - moderatorUserId Int + moderatorUserId String reason ActionReason note String? createdAt Int @@ -78,10 +78,10 @@ model Action { revertingActionId Int? @unique // Relationship to the user this action was performed on - user User @relation("UserActions", fields: [userId], references: [userId]) + user User @relation("UserActions", fields: [userId], references: [discordUserId]) // Relationship to the moderator who performed this action - moderator User @relation("ModeratorActions", fields: [moderatorUserId], references: [userId]) + moderator User @relation("ModeratorActions", fields: [moderatorUserId], references: [discordUserId]) // Self-referential relationship: this action reverts another action revertingAction Action? @relation("ActionCorrections", fields: [revertingActionId], references: [actionId]) diff --git a/src/commands/ban/index.ts b/src/commands/ban/index.ts new file mode 100644 index 0000000..3a46052 --- /dev/null +++ b/src/commands/ban/index.ts @@ -0,0 +1,181 @@ +import { ApplicationCommandOptionType, ChannelType } from "discord.js"; +import { ActionReason, ActionType } from "../../../generated/prisma/index.js"; +import { config } from "../../env.js"; +import { createActionDetails, createActionNotification } from "../../utils/action-embeds.js"; +import { BAN_TYPE_CHOICES, formatReason, REASON_CHOICES } from "../../utils/action-helpers.js"; +import { createAction, getDefaultExpiration } from "../../utils/actions.js"; +import { sendActionDM } from "../../utils/dm-user.js"; +import { createSlashCommand } from "../helpers.js"; + +export const ban = createSlashCommand({ + data: { + name: "ban", + description: "Ban a user from the server", + options: [ + { + name: "user", + description: "The user to ban", + type: ApplicationCommandOptionType.User, + required: true, + }, + { + name: "type", + description: "Ban type (permanent or temporary)", + type: ApplicationCommandOptionType.String, + required: true, + choices: BAN_TYPE_CHOICES, + }, + { + name: "reason", + description: "The reason for the ban", + type: ApplicationCommandOptionType.String, + required: true, + choices: REASON_CHOICES, + }, + { + name: "duration", + description: "Duration in days (only for temporary bans)", + type: ApplicationCommandOptionType.Integer, + required: false, + min_value: 1, + max_value: 365, + }, + { + name: "note", + description: "Additional note for the ban", + type: ApplicationCommandOptionType.String, + required: false, + }, + ], + }, + execute: async (interaction) => { + const targetUser = interaction.options.getUser("user", true); + const banType = interaction.options.getString("type", true); + const reason = interaction.options.getString("reason", true) as ActionReason; + const duration = interaction.options.getInteger("duration"); + const customNote = interaction.options.getString("note"); + + // Validation: If reason is OTHER, note must be provided + if (reason === ActionReason.OTHER && !customNote) { + await interaction.reply({ + content: "❌ When reason is 'Other', you must provide a note explaining the ban.", + ephemeral: true, + }); + return; + } + + // Validation: If temporary ban, duration must be provided + if (banType === "temporary" && !duration) { + await interaction.reply({ + content: "❌ You must provide a duration for temporary bans.", + ephemeral: true, + }); + return; + } + + // Construct the note based on requirements + let finalNote: string; + const reasonText = reason.toLowerCase().replace(/_/g, " "); + + if (!customNote) { + // Only reason provided + finalNote = `Banned for ${reasonText}`; + } else { + // Both reason and note provided + finalNote = `Banned for ${reasonText}: ${customNote}`; + } + + // Determine action type and expiration + const actionType = banType === "permanent" ? ActionType.BAN : ActionType.TEMP_BAN; + let expiresAt: number | undefined; + + if (banType === "temporary" && duration) { + // Calculate expiration for temporary ban + const now = Date.now(); + const daysInMs = duration * 24 * 60 * 60 * 1000; + expiresAt = Math.floor((now + daysInMs) / 1000); + } else { + // Permanent ban - use default expiration based on reason + expiresAt = getDefaultExpiration(reason); + } + + try { + // Defer reply as database and ban operations might take time + await interaction.deferReply(); + + // Ban the user from the guild + const member = await interaction.guild?.members.fetch(targetUser.id); + if (!member) { + await interaction.editReply({ + content: "❌ Could not find the user in this server.", + }); + return; + } + + // Create the action in the database first + const action = await createAction({ + userId: targetUser.id, + moderatorUserId: interaction.user.id, + type: actionType, + reason, + note: finalNote, + expiresAt, + }); + + // Send DM to user before banning (they won't be able to receive it after) + const dmResult = await sendActionDM({ + user: targetUser, + actionType, + reason: formatReason(reason), + note: customNote, + guildName: interaction.guild?.name, + actionId: action.actionId, + }); + + // Execute the ban + await member.ban({ + reason: finalNote, + }); + + const notificationEmbed = createActionNotification({ + user: targetUser, + actionType, + reason, + }); + + try { + await interaction.followUp({ + embeds: [notificationEmbed], + ephemeral: true, + }); + await interaction.followUp({ + content: dmResult.message, + ephemeral: true, + }); + } catch (error) { + console.error("Error sending notification embed:", error); + await interaction.followUp({ + content: "❌ An error occurred while sending the notification. Please try again later.", + ephemeral: true, + }); + } + + // Send verbose embed to action log channel + try { + const logChannel = await interaction.client.channels.fetch(config.channels.actionLogId); + if (logChannel && logChannel.type === ChannelType.GuildText) { + const detailsEmbed = createActionDetails(action, targetUser); + await logChannel.send({ embeds: [detailsEmbed] }); + } + } catch (logError) { + console.error("Error sending to action log channel:", logError); + } + } catch (error) { + console.error("Error creating ban action:", error); + await interaction.followUp({ + content: "❌ An error occurred while issuing the ban. Please try again later.", + ephemeral: true, + }); + } + }, +}); diff --git a/src/commands/index.ts b/src/commands/index.ts index b9df7dd..54303a5 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,8 +1,12 @@ +import { ban } from "./ban/index.js"; import { messagesCacheCommand } from "./messages-cache/index.js"; import { ping } from "./ping/index.js"; import { reportMessage } from "./report-message/index.js"; import type { Command } from "./types.js"; +import { warn } from "./warn/index.js"; export const commands = new Map( - [ping, reportMessage, messagesCacheCommand].flat().map((command) => [command.data.name, command]) + [ping, reportMessage, messagesCacheCommand, warn, ban] + .flat() + .map((command) => [command.data.name, command]) ); diff --git a/src/commands/warn/index.ts b/src/commands/warn/index.ts new file mode 100644 index 0000000..b4a73e0 --- /dev/null +++ b/src/commands/warn/index.ts @@ -0,0 +1,135 @@ +import { ApplicationCommandOptionType, ChannelType } from "discord.js"; +import { ActionReason, ActionType } from "../../../generated/prisma/index.js"; +import { config } from "../../env.js"; +import { createActionDetails, createActionNotification } from "../../utils/action-embeds.js"; +import { formatReason, REASON_CHOICES } from "../../utils/action-helpers.js"; +import { createAction, getDefaultExpiration } from "../../utils/actions.js"; +import { sendActionDM } from "../../utils/dm-user.js"; +import { createSlashCommand } from "../helpers.js"; + +export const warn = createSlashCommand({ + data: { + name: "warn", + description: "Issue a warning to a user", + options: [ + { + name: "user", + description: "The user to warn", + type: ApplicationCommandOptionType.User, + required: true, + }, + { + name: "reason", + description: "The reason for the warning", + type: ApplicationCommandOptionType.String, + required: true, + choices: REASON_CHOICES, + }, + { + name: "note", + description: "Additional note for the warning", + type: ApplicationCommandOptionType.String, + required: false, + }, + ], + }, + execute: async (interaction) => { + const targetUser = interaction.options.getUser("user", true); + const reason = interaction.options.getString("reason", true) as ActionReason; + const customNote = interaction.options.getString("note"); + + // Validation: If reason is OTHER, note must be provided + if (reason === ActionReason.OTHER && !customNote) { + await interaction.reply({ + content: "❌ When reason is 'Other', you must provide a note explaining the warning.", + ephemeral: true, + }); + return; + } + + // Construct the note based on requirements + let finalNote: string; + const reasonText = reason.toLowerCase().replace(/_/g, " "); + + if (!customNote) { + // Only reason provided + finalNote = `Warned for ${reasonText}`; + } else { + // Both reason and note provided + finalNote = `Warned for ${reasonText}: ${customNote}`; + } + + // Get expiration based on reason severity + const expiresAt = getDefaultExpiration(reason); + + try { + // Defer reply as database operations might take time + await interaction.deferReply(); + + // Create the action in the database + const action = await createAction({ + userId: targetUser.id, + moderatorUserId: interaction.user.id, + type: ActionType.WARN, + reason, + note: finalNote, + expiresAt, + }); + + // Send DM to the user + const dmResult = await sendActionDM({ + user: targetUser, + actionType: ActionType.WARN, + reason: formatReason(reason), + note: customNote, + guildName: interaction.guild?.name, + actionId: action.actionId, + }); + + const notificationEmbed = createActionNotification({ + user: targetUser, + actionType: ActionType.WARN, + reason, + }); + + try { + await interaction.followUp({ + embeds: [notificationEmbed], + ephemeral: true, + }); + // empheral message only the mod can see + await interaction.followUp({ + content: dmResult.message, + ephemeral: true, + }); + } catch (error) { + console.error("Error sending notification embed:", error); + await interaction.followUp({ + content: "❌ An error occurred while sending the notification. Please try again later.", + ephemeral: true, + }); + } + + // Send verbose embed to action log channel + try { + const logChannel = await interaction.client.channels.fetch(config.channels.actionLogId); + if (logChannel && logChannel.type === ChannelType.GuildText) { + const detailsEmbed = createActionDetails(action, targetUser); + await logChannel.send({ embeds: [detailsEmbed] }); + } + } catch (logError) { + console.error("Error sending to action log channel:", logError); + await interaction.followUp({ + content: "❌ An error occurred while sending the action log. Please try again later.", + ephemeral: true, + }); + } + } catch (error) { + console.error("Error creating warning action:", error); + await interaction.followUp({ + content: "❌ An error occurred while issuing the warning. Please try again later.", + ephemeral: true, + }); + } + }, +}); diff --git a/src/env.ts b/src/env.ts index ffe86e3..1327ab6 100644 --- a/src/env.ts +++ b/src/env.ts @@ -23,6 +23,9 @@ export const config = { roles: { regularId: requireEnv("ROLE_REGULAR_ID"), }, + channels: { + actionLogId: requireEnv("ACTION_LOG_CHANNEL_ID"), + }, // Add more config sections as needed: // database: { // url: requireEnv('DATABASE_URL'), diff --git a/src/events/ready/index.ts b/src/events/ready/index.ts index 215fc91..ff3e400 100644 --- a/src/events/ready/index.ts +++ b/src/events/ready/index.ts @@ -1,4 +1,6 @@ import { Events } from "discord.js"; +import cron from "node-cron"; +import { processExpiredActions } from "../../utils/action-expiration.js"; import { createEvent } from "../helpers.js"; export const readyEvent = createEvent( @@ -8,5 +10,18 @@ export const readyEvent = createEvent( }, async (client) => { console.log(`Ready! Logged in as ${client.user.tag}`); + + // Schedule weekly cron job to check for expired actions + // Runs at midnight (00:00) every Sunday + cron.schedule("0 0 * * 0", async () => { + console.log("⏰ Running weekly expiration check..."); + try { + await processExpiredActions(client); + } catch (error) { + console.error("Error during scheduled expiration check:", error); + } + }); + + console.log("📅 Scheduled weekly action expiration check (Sundays at midnight)"); } ); diff --git a/src/utils/action-embeds.ts b/src/utils/action-embeds.ts new file mode 100644 index 0000000..4c06965 --- /dev/null +++ b/src/utils/action-embeds.ts @@ -0,0 +1,130 @@ +import { EmbedBuilder, type User } from "discord.js"; +import type { Action, User as PrismaUser } from "../../generated/prisma/index.js"; +import { type ActionReason, ActionStatus, type ActionType } from "../../generated/prisma/index.js"; +import { + formatReason, + getActionColor, + getActionEmoji, + getActionTypeName, +} from "./action-helpers.js"; + +type ActionWithRelations = Action & { + user: PrismaUser; + moderator: PrismaUser; +}; + +/** + * Format timestamp for display + */ +function formatTimestamp(timestamp: number): string { + const date = new Date(timestamp * 1000); + return date.toLocaleString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +/** + * Format status for display + */ +function formatStatus(status: ActionStatus): string { + switch (status) { + case ActionStatus.ACTIVE: + return "🟢 Active"; + case ActionStatus.STALE: + return "🟡 Stale"; + case ActionStatus.REVERTED: + return "🔴 Reverted"; + default: + return status; + } +} + +/** + * Create a detailed Discord embed showing all action information (verbose) + * Used for database lookups and detailed action viewing + * Note: Action must be fetched with user and moderator relationships included + */ +export function createActionDetails(action: ActionWithRelations, targetUser?: User): EmbedBuilder { + // Extract Discord user IDs from the action relationships + const targetUserId = action.user.discordUserId; + const moderatorUserId = action.moderator.discordUserId; + + // Build title: "action type | reason | status" + const actionTypeName = getActionTypeName(action.type); + const reasonText = formatReason(action.reason); + const statusText = formatStatus(action.status).replace(/🟢 |🟡 |🔴 /, ""); // Remove emoji from title + const title = `${actionTypeName} | ${reasonText} | ${statusText}`; + + // Build footer text with timestamps and action ID (three rows) + const createdAt = formatTimestamp(action.createdAt); + const expiresAt = action.expiresAt ? formatTimestamp(action.expiresAt) : "Never"; + const footerText = `Created: ${createdAt}\nExpires: ${expiresAt}\nAction ID: #${action.actionId}`; + + // Get avatar URL if user object is provided + const avatarURL = targetUser?.displayAvatarURL(); + + const embed = new EmbedBuilder() + .setColor(getActionColor(action.type)) + .setAuthor({ + name: title, + iconURL: avatarURL, + }) + .addFields( + { + name: "User", + value: `<@${targetUserId}> (${targetUserId})`, + inline: false, + }, + { + name: "Moderator", + value: `<@${moderatorUserId}> (${moderatorUserId})`, + inline: false, + }, + { + name: "Reason", + value: formatReason(action.reason), + inline: true, + } + ) + .setFooter({ text: footerText }); + + // Add note if provided + if (action.note) { + embed.addFields({ + name: "Note", + value: action.note, + inline: true, + }); + } + + return embed; +} + +interface ActionNotificationOptions { + user: User; + actionType: ActionType; + reason: ActionReason; +} + +/** + * Create a simple action notification embed (short) + * Just shows "user received a for " in one compact line + */ +export function createActionNotification(options: ActionNotificationOptions): EmbedBuilder { + const { user, actionType, reason } = options; + + const emoji = getActionEmoji(actionType); + const actionName = getActionTypeName(actionType); + const formattedReason = formatReason(reason); + const title = `${emoji} ${user.tag} received a ${actionName} for ${formattedReason}`; + + const embed = new EmbedBuilder() + .setAuthor({ name: title, iconURL: user.displayAvatarURL() }) + .setColor(getActionColor(actionType)); + + return embed; +} diff --git a/src/utils/action-expiration.ts b/src/utils/action-expiration.ts new file mode 100644 index 0000000..5059811 --- /dev/null +++ b/src/utils/action-expiration.ts @@ -0,0 +1,84 @@ +import type { Client } from "discord.js"; +import { ActionStatus } from "../../generated/prisma/index.js"; +import { revertAction } from "./action-revert.js"; +import { db } from "./db.js"; + +interface ExpirationStats { + total: number; + succeeded: number; + failed: number; +} + +/** + * Process all expired actions by marking them as STALE and reverting temporary ones + * This function is called periodically by the cron job + * @param client The Discord client + * @returns Statistics about processed actions + */ +export async function processExpiredActions(client: Client): Promise { + const stats: ExpirationStats = { + total: 0, + succeeded: 0, + failed: 0, + }; + + try { + console.log("🔍 Checking for expired actions..."); + + const now = Math.floor(Date.now() / 1000); + + // Query for ACTIVE actions that have expired + const expiredActions = await db.action.findMany({ + where: { + status: ActionStatus.ACTIVE, + expiresAt: { + not: null, + lte: now, + }, + }, + include: { + user: true, + moderator: true, + }, + }); + + stats.total = expiredActions.length; + + if (expiredActions.length === 0) { + console.log("✅ No expired actions found"); + return stats; + } + + console.log(`📋 Found ${expiredActions.length} expired action(s)`); + + // Process each expired action + for (const action of expiredActions) { + try { + const result = await revertAction(action.actionId, client, { + reason: "Action expired automatically", + isAutomatic: true, + }); + + if (result.success) { + stats.succeeded++; + console.log(`✅ Processed expired action #${action.actionId} (${action.type})`); + } else { + stats.failed++; + console.error(`❌ Failed to process action #${action.actionId}: ${result.error}`); + } + } catch (error) { + stats.failed++; + console.error(`❌ Error processing action #${action.actionId}:`, error); + } + } + + console.log( + `📊 Expiration check complete: ${stats.succeeded} succeeded, ${stats.failed} failed out of ${stats.total} total` + ); + + return stats; + } catch (error) { + console.error("❌ Critical error during expiration check:", error); + return stats; + } +} diff --git a/src/utils/action-helpers.ts b/src/utils/action-helpers.ts new file mode 100644 index 0000000..6101142 --- /dev/null +++ b/src/utils/action-helpers.ts @@ -0,0 +1,100 @@ +import { ActionReason, ActionType } from "../../generated/prisma/index.js"; + +export function getActionColor(actionType: ActionType): number { + switch (actionType) { + case ActionType.WARN: + case ActionType.REPEL: + case ActionType.TIMEOUT: + // Yellow + return 0xffff00; + case ActionType.MUTE: + case ActionType.TEMP_MUTE: + case ActionType.KICK: + // Orange + return 0xffa500; + case ActionType.BAN: + case ActionType.TEMP_BAN: + // Red + return 0xff0000; + case ActionType.REVERT: + // Green + return 0x00ff00; + default: + // Default to Orange + return 0xffa500; + } +} + +export function formatReason(reason: ActionReason): string { + return reason + .toLowerCase() + .replace(/_/g, " ") + .replace(/\b\w/g, (char: string) => char.toUpperCase()); +} + +export function getActionEmoji(actionType: ActionType): string { + switch (actionType) { + case ActionType.WARN: + return "⚠️"; + case ActionType.REPEL: + return "🚫"; + case ActionType.TIMEOUT: + return "⏱️"; + case ActionType.MUTE: + case ActionType.TEMP_MUTE: + return "🔇"; + case ActionType.KICK: + return "👢"; + case ActionType.BAN: + case ActionType.TEMP_BAN: + return "🔨"; + case ActionType.REVERT: + return "✅"; + default: + return "📋"; + } +} + +export function getActionTypeName(actionType: ActionType): string { + switch (actionType) { + case ActionType.WARN: + return "Warning"; + case ActionType.REPEL: + return "Repel"; + case ActionType.TIMEOUT: + return "Timeout"; + case ActionType.MUTE: + return "Mute"; + case ActionType.TEMP_MUTE: + return "Temporary Mute"; + case ActionType.KICK: + return "Kick"; + case ActionType.BAN: + return "Ban"; + case ActionType.TEMP_BAN: + return "Temporary Ban"; + case ActionType.REVERT: + return "Revert"; + default: + return "Action"; + } +} + +export const BAN_TYPE_CHOICES = [ + { name: "Permanent", value: "permanent" }, + { name: "Temporary", value: "temporary" }, +]; +Object.freeze(BAN_TYPE_CHOICES); + +export const REASON_CHOICES = [ + { name: "Spam", value: ActionReason.SPAM }, + { name: "Scam", value: ActionReason.SCAM }, + { name: "Disruptive", value: ActionReason.DISRUPTIVE }, + { name: "NSFW", value: ActionReason.NSFW }, + { name: "Hate Speech", value: ActionReason.HATE_SPEECH }, + { name: "Self Promotion", value: ActionReason.SELF_PROMOTION }, + { name: "Job Posting", value: ActionReason.JOB_POSTING }, + { name: "For Hire", value: ActionReason.FOR_HIRE }, + { name: "Other", value: ActionReason.OTHER }, +]; +Object.freeze(REASON_CHOICES); diff --git a/src/utils/action-revert.ts b/src/utils/action-revert.ts new file mode 100644 index 0000000..d0e1cdb --- /dev/null +++ b/src/utils/action-revert.ts @@ -0,0 +1,203 @@ +import type { Client } from "discord.js"; +import { ChannelType } from "discord.js"; +import { ActionStatus, ActionType } from "../../generated/prisma/index.js"; +import { config } from "../env.js"; +import { createActionDetails } from "./action-embeds.js"; +import type { ActionWithRelations } from "./actions.js"; +import { createRevertAction } from "./actions.js"; +import { db } from "./db.js"; + +interface RevertOptions { + reason?: string; + isAutomatic?: boolean; +} + +interface RevertResult { + success: boolean; + error?: string; + revertAction?: ActionWithRelations; +} + +/** + * Revert a moderation action by creating a REVERT action and removing Discord punishments + * @param actionId The ID of the action to revert + * @param client The Discord client + * @param options Revert options (reason and whether it's automatic) + * @returns Result object with success status and optional revert action + */ +export async function revertAction( + actionId: number, + client: Client, + options: RevertOptions = {} +): Promise { + const { reason = "Action reverted", isAutomatic = false } = options; + + try { + // Fetch the original action with relationships + const originalAction = await db.action.findUnique({ + where: { actionId }, + include: { + user: true, + moderator: true, + }, + }); + + if (!originalAction) { + return { + success: false, + error: `Action with ID ${actionId} not found`, + }; + } + + // Check if action is already reverted or stale + if (originalAction.status !== ActionStatus.ACTIVE) { + return { + success: false, + error: `Action #${actionId} is already ${originalAction.status.toLowerCase()}`, + }; + } + + // Get bot user ID + if (!client.user) { + return { + success: false, + error: "Bot user is not available", + }; + } + + // Create the REVERT action + const revertAction = await createRevertAction({ + originalActionId: actionId, + moderatorUserId: client.user.id, + note: reason, + }); + + // Update original action status based on context + const newStatus = isAutomatic ? ActionStatus.STALE : ActionStatus.REVERTED; + await db.action.update({ + where: { actionId }, + data: { status: newStatus }, + }); + + // If it's a temporary action, remove the Discord punishment + const isTemporaryAction = + originalAction.type === ActionType.TEMP_BAN || + originalAction.type === ActionType.TEMP_MUTE || + originalAction.type === ActionType.TIMEOUT; + + if (isTemporaryAction) { + await removeDiscordPunishment(originalAction, client); + } + + // Post REVERT details to action log channel + try { + const logChannel = await client.channels.fetch(config.channels.actionLogId); + if (logChannel && logChannel.type === ChannelType.GuildText) { + const detailsEmbed = createActionDetails(revertAction); + await logChannel.send({ embeds: [detailsEmbed] }); + } + } catch (logError) { + console.error("Error posting revert action to log channel:", logError); + // Don't fail the entire revert if logging fails + } + + console.log( + `✅ Action #${actionId} ${isAutomatic ? "expired automatically" : "manually reverted"} (status: ${newStatus})` + ); + + return { + success: true, + revertAction, + }; + } catch (error) { + console.error(`Error reverting action #${actionId}:`, error); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } +} + +/** + * Remove Discord punishment for temporary actions + * @param action The action to remove punishment for + * @param client The Discord client + */ +async function removeDiscordPunishment(action: ActionWithRelations, client: Client): Promise { + try { + const guild = await client.guilds.fetch(config.discord.serverId); + if (!guild) { + console.error(`Guild with ID ${config.discord.serverId} not found`); + return; + } + + const userId = action.user.discordUserId; + + switch (action.type) { + case ActionType.TEMP_BAN: { + try { + await guild.members.unban(userId, `Temporary ban expired (Action #${action.actionId})`); + console.log(`✅ Unbanned user ${userId}`); + } catch (error: unknown) { + // Error code 10026 = Unknown Ban (user is not banned) + if (error && typeof error === "object" && "code" in error && error.code === 10026) { + console.log(`ℹ️ User ${userId} is not currently banned`); + } else { + console.error(`Error unbanning user ${userId}:`, error); + } + } + break; + } + + case ActionType.TEMP_MUTE: { + try { + const member = await guild.members.fetch(userId); + if (member) { + // Remove voice mute + await member.voice.setMute( + false, + `Temporary mute expired (Action #${action.actionId})` + ); + console.log(`✅ Unmuted user ${userId} in voice chat`); + } else { + console.log(`ℹ️ User ${userId} is not in the server (cannot unmute)`); + } + } catch (error: unknown) { + // Error code 10007 = Unknown Member + if (error && typeof error === "object" && "code" in error && error.code === 10007) { + console.log(`ℹ️ User ${userId} is not in the server`); + } else { + console.error(`Error unmuting user ${userId}:`, error); + } + } + break; + } + + case ActionType.TIMEOUT: { + try { + const member = await guild.members.fetch(userId); + if (member) { + // Remove timeout + await member.timeout(null, `Timeout expired (Action #${action.actionId})`); + console.log(`✅ Removed timeout for user ${userId}`); + } else { + console.log(`ℹ️ User ${userId} is not in the server (cannot remove timeout)`); + } + } catch (error: unknown) { + // Error code 10007 = Unknown Member + if (error && typeof error === "object" && "code" in error && error.code === 10007) { + console.log(`ℹ️ User ${userId} is not in the server`); + } else { + console.error(`Error removing timeout for user ${userId}:`, error); + } + } + break; + } + + default: + console.log(`ℹ️ Action type ${action.type} does not require Discord punishment removal`); + } + } catch (error) { + console.error("Error removing Discord punishment:", error); + } +} diff --git a/src/utils/actions.ts b/src/utils/actions.ts new file mode 100644 index 0000000..e211377 --- /dev/null +++ b/src/utils/actions.ts @@ -0,0 +1,139 @@ +import type { Action, User } from "../../generated/prisma/index.js"; +import { ActionReason, ActionType } from "../../generated/prisma/index.js"; +import { db } from "./db.js"; + +export type ActionWithRelations = Action & { + user: User; + moderator: User; +}; + +/** + * Get default expiration time based on the severity of the action reason + * @param reason The action reason + * @returns Timestamp for expiration (in seconds since epoch) + */ +export const getDefaultExpiration = (reason: ActionReason): number => { + const now = Date.now(); + const monthsToMs = (months: number) => months * 30 * 24 * 60 * 60 * 1000; + + const expirationMonths: Record = { + HATE_SPEECH: 12, // severe + NSFW: 9, // severe + SCAM: 9, // severe + DISRUPTIVE: 6, // moderate + SPAM: 3, // default + SELF_PROMOTION: 3, // default + JOB_POSTING: 3, // default + FOR_HIRE: 3, // default + OTHER: 3, // default + }; + + const months = expirationMonths[reason]; + const expirationTimestamp = now + monthsToMs(months); + + // Convert to seconds (Prisma schema uses Int for timestamps) + return Math.floor(expirationTimestamp / 1000); +}; + +/** + * Create a moderation action in the database + * @param params Action parameters + * @returns The created action record + */ +export const createAction = async (params: { + userId: string; + moderatorUserId: string; + type: ActionType; + reason: ActionReason; + note?: string; + expiresAt?: number; +}): Promise => { + const { userId, moderatorUserId, type, reason, note, expiresAt } = params; + + // Find or create the target user + const targetUser = await db.user.upsert({ + where: { discordUserId: userId }, + update: {}, + create: { discordUserId: userId }, + }); + + // Find or create the moderator user + const moderator = await db.user.upsert({ + where: { discordUserId: moderatorUserId }, + update: {}, + create: { discordUserId: moderatorUserId }, + }); + + // Create the action with user and moderator relationships + const action = await db.action.create({ + data: { + userId: targetUser.discordUserId, + moderatorUserId: moderator.discordUserId, + type, + reason, + note: note ?? null, + createdAt: Math.floor(Date.now() / 1000), // Current timestamp in seconds + expiresAt: expiresAt ?? null, + }, + include: { + user: true, + moderator: true, + }, + }); + + return action; +}; + +/** + * Create a REVERT action to reverse a previous moderation action + * Note: This function does NOT update the original action's status - caller must handle that + * @param params Revert action parameters + * @returns The created REVERT action record with relationships + */ +export const createRevertAction = async (params: { + originalActionId: number; + moderatorUserId: string; + note: string; +}): Promise => { + const { originalActionId, moderatorUserId, note } = params; + + // Fetch the original action with relationships + const originalAction = await db.action.findUnique({ + where: { actionId: originalActionId }, + include: { + user: true, + moderator: true, + }, + }); + + if (!originalAction) { + throw new Error(`Action with ID ${originalActionId} not found`); + } + + // Find or create the moderator user (bot user) + const moderator = await db.user.upsert({ + where: { discordUserId: moderatorUserId }, + update: {}, + create: { discordUserId: moderatorUserId }, + }); + + // Create the REVERT action + const revertAction = await db.action.create({ + data: { + userId: originalAction.userId, // Same user as original action + moderatorUserId: moderator.discordUserId, + type: ActionType.REVERT, + reason: ActionReason.OTHER, + note, + createdAt: Math.floor(Date.now() / 1000), + expiresAt: null, // REVERT actions don't expire + revertingActionId: originalActionId, // Link to original action + }, + include: { + user: true, + moderator: true, + }, + }); + + return revertAction; +}; diff --git a/src/utils/db.ts b/src/utils/db.ts new file mode 100644 index 0000000..db67b32 --- /dev/null +++ b/src/utils/db.ts @@ -0,0 +1,21 @@ +import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3"; +import { PrismaClient } from "../../generated/prisma/index.js"; + +// Singleton PrismaClient instance +let prisma: PrismaClient | null = null; + +export const getPrismaClient = (): PrismaClient => { + if (!prisma) { + // Create the Prisma adapter with SQLite database URL + const adapter = new PrismaBetterSqlite3({ + url: "file:./data/moderation.db", + }); + + // Initialize Prisma Client with the adapter + prisma = new PrismaClient({ adapter }); + } + return prisma; +}; + +// Export a default instance for convenience +export const db = getPrismaClient(); diff --git a/src/utils/dm-user.ts b/src/utils/dm-user.ts new file mode 100644 index 0000000..11ed7ee --- /dev/null +++ b/src/utils/dm-user.ts @@ -0,0 +1,79 @@ +import type { User } from "discord.js"; +import type { ActionType } from "../../generated/prisma/index.js"; +import { getActionEmoji, getActionTypeName } from "./action-helpers.js"; + +interface SendActionDMOptions { + user: User; + actionType: ActionType; + reason: string; + note?: string | null; + guildName?: string; + actionId: number; +} + +interface DMResult { + success: boolean; + message: string; + + error?: string; +} + +/** + * Send a DM to a user informing them of a moderation action + * @returns Object with success status and optional error message + */ +export async function sendActionDM(options: SendActionDMOptions): Promise { + const { user, actionType, reason, note, guildName, actionId } = options; + + try { + const emoji = getActionEmoji(actionType); + const actionName = getActionTypeName(actionType); + const serverName = guildName || "the server"; + + // Build the embed + const embed = { + color: 0xe74c3c, // red-ish color for alert (customize as desired) + title: `${emoji} ${actionName}`, + description: `You have received a **${actionName.toLowerCase()}** in **${serverName}**.`, + fields: [ + { + name: "Reason", + value: reason, + inline: true, + }, + ], + timestamp: new Date().toISOString(), + footer: { + text: `Action ID: #${actionId}\nIf you have questions about this action, you may contact the server moderators.`, + }, + }; + + if (note) { + embed.fields.push({ + name: "Details", + value: note, + inline: true, + }); + } + + await user.send({ embeds: [embed] }); + return { success: true, message: "✅ User was notified via DM" }; + } catch (error) { + // Common reasons for failure: + // - User has DMs disabled + // - User has blocked the bot + // - User is not in a mutual server + if (error instanceof Error) { + console.error("Error sending DM to user:", error.message); + return { + success: false, + message: "⚠️ Could not send DM to user (they may have DMs disabled)", + }; + } + console.error("Error sending DM to user: Unknown error"); + return { + success: false, + message: "⚠️ Could not send DM to user (they may have DMs disabled)", + }; + } +}