diff --git a/.env.example b/.env.example index f481229..7f47c3f 100644 --- a/.env.example +++ b/.env.example @@ -36,11 +36,7 @@ LOG_LEVEL=info NODE_ENV=development # Moderation Configuration -# TEXT_GUILD_ID falls back to legacy MONITOR_GUILD_ID when omitted. -MONITOR_GUILD_ID=legacy_text_guild_id -TEXT_GUILD_ID=text_guild_id -TEXT_CHANNEL_ID=text_channel_id -PICSER_UPLOAD_URL=https://picser.asepharyana.tech/api/upload +TELE_UPLOAD_URL=https://upload.asepharyana.tech/api/upload ATTACHMENT_UPLOAD_TIMEOUT_MS=30000 ATTACHMENT_MAX_SIZE_MB=100 ATTACHMENT_RETRY_ATTEMPTS=3 diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index 2aa7205..c2ddf74 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -1,6 +1,5 @@ export default { plugins: { - tailwindcss: {}, - autoprefixer: {}, + "@tailwindcss/postcss": {}, }, }; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 190f524..8b62ef9 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1,6 +1,5 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +@import "tailwindcss"; +@config "../tailwind.config.js"; @layer base { :root { @@ -25,11 +24,14 @@ } * { - @apply border-border; + border-color: hsl(var(--border)); } body { - @apply bg-background text-foreground antialiased; + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } diff --git a/package.json b/package.json index 4a59b57..d38a2f6 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ }, "devDependencies": { "@biomejs/biome": "latest", + "@tailwindcss/postcss": "^4.3.0", "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", "@types/node": "^25.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e5544d..4ad39b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,7 +31,7 @@ importers: version: 8.20.0 '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.0.2(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(tsx@4.22.2)(yaml@2.9.0)) + version: 6.0.2(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) better-sqlite3: specifier: ^12.10.0 version: 12.10.0 @@ -85,7 +85,7 @@ importers: version: 3.6.0 vite: specifier: ^8.0.13 - version: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(tsx@4.22.2)(yaml@2.9.0) + version: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) winston: specifier: ^3.19.0 version: 3.19.0 @@ -99,6 +99,9 @@ importers: '@biomejs/biome': specifier: latest version: 2.4.15 + '@tailwindcss/postcss': + specifier: ^4.3.0 + version: 4.3.0 '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 @@ -134,7 +137,7 @@ importers: version: 4.22.2 vitest: specifier: latest - version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.0)(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(tsx@4.22.2)(yaml@2.9.0)) + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.0)(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) vendor/discord-video-stream: dependencies: @@ -269,6 +272,10 @@ packages: '@actions/io@1.1.3': resolution: {integrity: sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==} + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -1073,9 +1080,22 @@ packages: resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jsdevtools/ez-spawn@3.0.4': resolution: {integrity: sha512-f5DRIOZf7wxogefH03RjMPMdBF7ADTWUMoOs9kaJo06EfwF+aFhMZMDZxHg/Xe12hptN9xoZjGso2fdjapBRIA==} engines: {node: '>=10'} @@ -1676,6 +1696,98 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/node@4.3.0': + resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} + + '@tailwindcss/oxide-android-arm64@4.3.0': + resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.0': + resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.0': + resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} + engines: {node: '>= 20'} + + '@tailwindcss/postcss@4.3.0': + resolution: {integrity: sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==} + '@tsd/typescript@5.9.3': resolution: {integrity: sha512-JSSdNiS0wgd8GHhBwnMAI18Y8XPhLVN+dNelPfZCXFhy9Lb3NbnFyp9JKxxr54jSUkEJPk3cidvCoHducSaRMQ==} engines: {node: '>=14.17'} @@ -2399,6 +2511,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.21.5: + resolution: {integrity: sha512-mLCNbrQli11K1ySUmuNt4ZUB3OpGIDq4q2vTBTf5cL2lpsRjI9QKqSD0ndjW8FyvcW/Jj46gMe9syyHAsvMa/A==} + engines: {node: '>=10.13.0'} + entities@2.1.0: resolution: {integrity: sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==} @@ -2928,6 +3044,10 @@ packages: resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3995,6 +4115,10 @@ packages: tailwindcss@4.3.0: resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + tar-fs@2.1.4: resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} @@ -4517,6 +4641,8 @@ snapshots: '@actions/io@1.1.3': {} + '@alloc/quick-lru@5.2.0': {} + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -5023,8 +5149,25 @@ snapshots: dependencies: '@sinclair/typebox': 0.27.10 + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@jsdevtools/ez-spawn@3.0.4': dependencies: call-me-maybe: 1.0.2 @@ -5578,6 +5721,75 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@tailwindcss/node@4.3.0': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.21.5 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.0 + + '@tailwindcss/oxide-android-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide@4.3.0': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-x64': 4.3.0 + '@tailwindcss/oxide-freebsd-x64': 4.3.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-x64-musl': 4.3.0 + '@tailwindcss/oxide-wasm32-wasi': 4.3.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 + + '@tailwindcss/postcss@4.3.0': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.3.0 + '@tailwindcss/oxide': 4.3.0 + postcss: 8.5.14 + tailwindcss: 4.3.0 + '@tsd/typescript@5.9.3': {} '@tybys/wasm-util@0.10.2': @@ -5693,10 +5905,10 @@ snapshots: dependencies: '@types/node': 25.8.0 - '@vitejs/plugin-react@6.0.2(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(tsx@4.22.2)(yaml@2.9.0))': + '@vitejs/plugin-react@6.0.2(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(tsx@4.22.2)(yaml@2.9.0) + vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) '@vitest/expect@4.1.6': dependencies: @@ -5707,13 +5919,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.6(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(tsx@4.22.2)(yaml@2.9.0))': + '@vitest/mocker@4.1.6(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(tsx@4.22.2)(yaml@2.9.0) + vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) '@vitest/pretty-format@4.1.6': dependencies: @@ -6217,6 +6429,11 @@ snapshots: dependencies: once: 1.4.0 + enhanced-resolve@5.21.5: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + entities@2.1.0: {} error-ex@1.3.4: @@ -6848,6 +7065,8 @@ snapshots: jest-get-type@29.6.3: {} + jiti@2.7.0: {} + js-tokens@4.0.0: {} js-yaml@3.14.2: @@ -7950,6 +8169,8 @@ snapshots: tailwindcss@4.3.0: {} + tapable@2.3.3: {} + tar-fs@2.1.4: dependencies: chownr: 1.1.4 @@ -8173,7 +8394,7 @@ snapshots: vary@1.1.2: {} - vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(tsx@4.22.2)(yaml@2.9.0): + vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -8184,13 +8405,14 @@ snapshots: '@types/node': 25.9.0 esbuild: 0.28.0 fsevents: 2.3.3 + jiti: 2.7.0 tsx: 4.22.2 yaml: 2.9.0 - vitest@4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.0)(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(tsx@4.22.2)(yaml@2.9.0)): + vitest@4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.0)(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.6 - '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(tsx@4.22.2)(yaml@2.9.0)) + '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.6 '@vitest/runner': 4.1.6 '@vitest/snapshot': 4.1.6 @@ -8207,7 +8429,7 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(tsx@4.22.2)(yaml@2.9.0) + vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 diff --git a/postcss.config.js b/postcss.config.js index 7738160..c2ddf74 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,5 +1,5 @@ export default { plugins: { - autoprefixer: {}, + "@tailwindcss/postcss": {}, }, }; diff --git a/src/config.ts b/src/config.ts index 0d283d0..771ae27 100644 --- a/src/config.ts +++ b/src/config.ts @@ -37,10 +37,10 @@ const configSchema = z .enum(["development", "production", "test"]) .default("development"), MONITOR_GUILD_ID: z.string().min(1).optional(), - PICSER_UPLOAD_URL: z + TELE_UPLOAD_URL: z .string() .url() - .default("https://picser.asepharyana.tech/api/upload"), + .default("https://upload.asepharyana.tech/api/upload"), ATTACHMENT_UPLOAD_TIMEOUT_MS: z.coerce.number().positive().default(30000), ATTACHMENT_MAX_SIZE_MB: z.coerce.number().positive().default(100), ATTACHMENT_RETRY_ATTEMPTS: z.coerce.number().positive().default(3), @@ -69,6 +69,19 @@ const configSchema = z .default("https://9router.asepharyana.tech/v1"), AI_LLM_MODEL: z.string().default("free"), AI_ANALYSIS_TIMEOUT_MS: z.coerce.number().positive().default(30000), + AI_ANALYSIS_DEBOUNCE_MS: z.coerce.number().positive().default(500), + AI_ANALYSIS_RECOVERY_INTERVAL_MS: z.coerce + .number() + .positive() + .default(15000), + AI_ANALYSIS_ERROR_COOLDOWN_MS: z.coerce.number().positive().default(30000), + AI_ANALYSIS_MAX_BATCH_SIZE: z.coerce.number().int().positive().default(25), + AI_ANALYSIS_MAX_CONTEXT_TOKENS: z.coerce.number().positive().default(8000), + AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT: z.coerce + .number() + .int() + .positive() + .default(20), DATABASE_TYPE: z.enum(["sqlite", "postgres"]).default("sqlite"), DATABASE_URL: z.string().optional(), POSTGRES_HOST: z.string().default("localhost"), diff --git a/src/moderation/aiAnalysisWorker.ts b/src/moderation/aiAnalysisWorker.ts index d9dfdd0..0e1422c 100644 --- a/src/moderation/aiAnalysisWorker.ts +++ b/src/moderation/aiAnalysisWorker.ts @@ -1,4 +1,5 @@ import { parentPort } from "node:worker_threads"; +import { config } from "../config.ts"; import { initializeDatabase } from "../database/drizzle.ts"; import { buildConversationPromptMessages } from "./conversationContext.ts"; import { runModerationAnalysis } from "./llmModerationClient.ts"; @@ -9,8 +10,6 @@ import { } from "./messageStore.ts"; import type { MessageRecord } from "./types"; -const MAX_CONTEXT_TOKENS = 8000; - let dbInitialized = false; interface AnalysisWorkerRequest { @@ -58,13 +57,13 @@ async function processAnalysisRequest({ channelId: firstMessage.channel_id, threadId: firstMessage.thread_id, beforeCreatedAt: firstMessage.created_at, - limit: 20, + limit: config.AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT, }); const promptMessages = buildConversationPromptMessages({ contextBefore, targets: messages, - maxTokens: MAX_CONTEXT_TOKENS, + maxTokens: config.AI_ANALYSIS_MAX_CONTEXT_TOKENS, }); const targetIds = messages.map((m) => m.id); diff --git a/src/moderation/aiAnalyzer.ts b/src/moderation/aiAnalyzer.ts index 832c1da..eeb5cdc 100644 --- a/src/moderation/aiAnalyzer.ts +++ b/src/moderation/aiAnalyzer.ts @@ -31,10 +31,6 @@ const conversationErrorCooldown = new Map(); let activeRequests = 0; let lastError: string | null = null; -const DEBOUNCE_MS = 1500; -const RECOVERY_INTERVAL_MS = 15000; -const ERROR_COOLDOWN_MS = 30000; -const MAX_BATCH_SIZE = 25; interface AnalysisWorkerResponse { ok: boolean; @@ -98,7 +94,7 @@ async function processBatch( lastError = result.error ?? "Analysis worker failed"; conversationErrorCooldown.set( conversationKey, - Date.now() + ERROR_COOLDOWN_MS, + Date.now() + config.AI_ANALYSIS_ERROR_COOLDOWN_MS, ); logger.error( { @@ -106,7 +102,9 @@ async function processBatch( error: lastError, messageCount: messages.length, messageIds: messages.map((m) => m.id), - cooldownUntil: new Date(Date.now() + ERROR_COOLDOWN_MS).toISOString(), + cooldownUntil: new Date( + Date.now() + config.AI_ANALYSIS_ERROR_COOLDOWN_MS, + ).toISOString(), timestamp: new Date().toISOString(), }, "Batch analysis failed, will retry after cooldown", @@ -120,7 +118,7 @@ async function processBatch( const errorStack = error instanceof Error ? error.stack : undefined; conversationErrorCooldown.set( conversationKey, - Date.now() + ERROR_COOLDOWN_MS, + Date.now() + config.AI_ANALYSIS_ERROR_COOLDOWN_MS, ); logger.error( { @@ -129,7 +127,9 @@ async function processBatch( stack: errorStack, messageCount: messages.length, messageIds: messages.map((m) => m.id), - cooldownUntil: new Date(Date.now() + ERROR_COOLDOWN_MS).toISOString(), + cooldownUntil: new Date( + Date.now() + config.AI_ANALYSIS_ERROR_COOLDOWN_MS, + ).toISOString(), timestamp: new Date().toISOString(), }, "Analysis worker failed, will retry after cooldown", @@ -188,7 +188,7 @@ function scheduleConversationAnalysis(conversationKey: string): void { } // Always use shorter debounce for immediate processing (no concurrency limit) - const debounceTime = Math.min(DEBOUNCE_MS, 500); + const debounceTime = config.AI_ANALYSIS_DEBOUNCE_MS; // Set new debounced timer const timer = setTimeout(async () => { @@ -197,7 +197,7 @@ function scheduleConversationAnalysis(conversationKey: string): void { // Get pending messages for this conversation const messages = await getPendingMessagesByConversation( conversationKey, - MAX_BATCH_SIZE, + config.AI_ANALYSIS_MAX_BATCH_SIZE, ); if (messages.length > 0) { @@ -290,5 +290,5 @@ export function startPendingAIAnalysisWorker(): void { } catch (error) { logger.error({ error }, "Pending AI analysis recovery worker failed"); } - }, RECOVERY_INTERVAL_MS); + }, config.AI_ANALYSIS_RECOVERY_INTERVAL_MS); } diff --git a/src/moderation/attachmentUploader.ts b/src/moderation/attachmentUploader.ts index 5065578..7bb0d61 100644 --- a/src/moderation/attachmentUploader.ts +++ b/src/moderation/attachmentUploader.ts @@ -1,6 +1,6 @@ import { config } from "../config"; import { createChildLogger } from "../logger"; -import { retryWithBackoff } from "../retry"; +import { uploadToTele } from "../uploader/teleUpload"; import { updateAttachmentAsFailedUpload, updateAttachmentAsUploaded, @@ -8,83 +8,26 @@ import { const logger = createChildLogger("attachment-uploader"); -const ATTACHMENT_UPLOAD_RETRY_OPTIONS = { - retries: config.ATTACHMENT_RETRY_ATTEMPTS, - minTimeout: 1000, - maxTimeout: 5000, - logger, -} as const; - function toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -export interface PicserUploadResponse { - success: boolean; - filename: string; - urls: { - raw_commit?: string; - [key: string]: string | undefined; - }; - size: number; - type: string; -} - -export interface ParsedUploadResponse { - success: boolean; - url: string; - filename: string; - size: number; - type: string; -} - -export function parseUploadResponse( - response: PicserUploadResponse, -): ParsedUploadResponse { - if (!response.success) { - throw new Error("Upload failed: success=false"); - } - - const rawCommitUrl = response.urls.raw_commit; - if (!rawCommitUrl) { - throw new Error("Upload response missing raw_commit URL"); - } - - return { - success: true, - url: rawCommitUrl, - filename: response.filename, - size: response.size, - type: response.type, - }; -} - -export async function uploadAttachmentToPicser( +export async function uploadAttachmentToTele( fileBuffer: Buffer, filename: string, -): Promise { - const formData = new FormData(); - const blob = new Blob([new Uint8Array(fileBuffer)], { - type: "application/octet-stream", - }); - formData.append("file", blob, filename); - +): Promise { try { - const response = await retryWithBackoff(async () => { - const res = await fetch(config.PICSER_UPLOAD_URL, { - method: "POST", - body: formData, - signal: AbortSignal.timeout(config.ATTACHMENT_UPLOAD_TIMEOUT_MS), - }); + const result = await uploadToTele({ + buffer: fileBuffer, + filename, + contentType: "application/octet-stream", + uploadUrl: config.TELE_UPLOAD_URL, + timeoutMs: config.ATTACHMENT_UPLOAD_TIMEOUT_MS, + retries: config.ATTACHMENT_RETRY_ATTEMPTS, + logger, + }); - if (!res.ok) { - throw new Error(`Upload failed with status ${res.status}`); - } - - return res.json() as Promise; - }, ATTACHMENT_UPLOAD_RETRY_OPTIONS); - - return parseUploadResponse(response); + return result.url; } catch (error) { logger.error( { @@ -133,9 +76,9 @@ export async function processAttachmentUpload( ); } - const result = await uploadAttachmentToPicser(buffer, filename); + const uploadedUrl = await uploadAttachmentToTele(buffer, filename); - await updateAttachmentAsUploaded(attachmentId, result.url, Date.now()); + await updateAttachmentAsUploaded(attachmentId, uploadedUrl, Date.now()); } catch (error) { const errorMsg = toErrorMessage(error); await updateAttachmentAsFailedUpload(attachmentId, errorMsg); diff --git a/src/moderation/conversationContext.ts b/src/moderation/conversationContext.ts index cc8bfce..26434b1 100644 --- a/src/moderation/conversationContext.ts +++ b/src/moderation/conversationContext.ts @@ -32,45 +32,39 @@ export function buildConversationPromptMessages( ): string[] { const { contextBefore, targets, maxTokens } = input; - // Format all messages const formatMessage = (msg: MessageRecord, label: string): string => { const content = msg.edited_content ?? msg.content; const timestamp = formatTimestamp(msg.created_at); return `[${label}] id=${msg.id} time=${timestamp} user=${msg.username}: ${content}`; }; - const targetLines = targets.map((msg) => formatMessage(msg, "target")); - const contextLines = contextBefore.map((msg) => - formatMessage(msg, "context"), - ); + const targetEntries = targets.map((msg) => ({ + msg, + label: "target" as const, + line: formatMessage(msg, "target"), + })); - // Calculate tokens for targets (always include) - let usedTokens = targetLines.reduce( - (sum, line) => sum + estimateTokens(line), + let usedTokens = targetEntries.reduce( + (sum, entry) => sum + estimateTokens(entry.line), 0, ); - // Add context lines in reverse chronological order (most recent first) - // until we hit the token budget - const selectedContextLines: string[] = []; - for (let i = contextLines.length - 1; i >= 0; i--) { - const line = contextLines[i]; + const selectedContextEntries: Array<{ + msg: MessageRecord; + label: "context"; + line: string; + }> = []; + for (let i = contextBefore.length - 1; i >= 0; i--) { + const msg = contextBefore[i]; + const line = formatMessage(msg, "context"); const lineTokens = estimateTokens(line); if (usedTokens + lineTokens <= maxTokens) { - selectedContextLines.unshift(line); // prepend to maintain chronological order + selectedContextEntries.push({ msg, label: "context", line }); usedTokens += lineTokens; } } - // Combine: context (chronological) + targets (chronological) - const allMessages = [...selectedContextLines, ...targetLines]; - - // Sort by timestamp to ensure chronological order - allMessages.sort((a, b) => { - const timeA = a.match(/time=([^\s]+)/)?.[1] ?? ""; - const timeB = b.match(/time=([^\s]+)/)?.[1] ?? ""; - return timeA.localeCompare(timeB); - }); - - return allMessages; + return [...selectedContextEntries, ...targetEntries] + .sort((a, b) => a.msg.created_at - b.msg.created_at) + .map((entry) => entry.line); } diff --git a/src/moderation/llmModerationClient.ts b/src/moderation/llmModerationClient.ts index cfe535e..3688d30 100644 --- a/src/moderation/llmModerationClient.ts +++ b/src/moderation/llmModerationClient.ts @@ -17,6 +17,48 @@ interface RawModerationResponse { results: RawModerationResult[]; } +function parseFirstJsonObject(content: string): unknown { + for ( + let start = content.indexOf("{"); + start !== -1; + start = content.indexOf("{", start + 1) + ) { + let depth = 0; + let inString = false; + let escaped = false; + + for (let index = start; index < content.length; index++) { + const char = content[index]; + + if (escaped) { + escaped = false; + continue; + } + + if (char === "\\") { + escaped = inString; + continue; + } + + if (char === '"') { + inString = !inString; + continue; + } + + if (inString) continue; + + if (char === "{") depth++; + if (char === "}") depth--; + + if (depth === 0) { + return JSON.parse(content.slice(start, index + 1)); + } + } + } + + throw new Error("No JSON object found in response body"); +} + /** * Helper to extract a JSON object from a potentially conversational or markdown-wrapped string. * It first scans for markdown json code blocks, then falls back to trying all start/end brace pairs from largest to smallest. @@ -321,17 +363,11 @@ export async function runModerationAnalysis( const targetIds = targets.map((t) => t.id); - // Build prompt const messagesText = targets .map((msg) => `[${msg.id}] ${msg.username}: ${msg.content}`) .join("\n"); - const prompt = `You are a content moderation assistant. Analyze the following messages for policy violations. - -Context: ${contextText} - -Messages to analyze: -${messagesText} + const systemPrompt = `You are a content moderation assistant. Analyze messages for policy violations. For each message, respond with a JSON object containing a "results" array. CRITICAL: You MUST return the "message_id" EXACTLY as provided in the input, and it MUST be wrapped in double quotes as a STRING. Do not treat IDs as numbers. @@ -346,12 +382,21 @@ Each result must have: Do not include reasoning, analysis steps, markdown, prose, XML tags, or comments. Return ONLY valid JSON, no other text.`; + const userPrompt = `Context: ${contextText} + +Messages to analyze: +${messagesText}`; + // Check for image attachments to support multimodal analysis const targetIdSet = new Set(targets.map((t) => t.id)); + const getAttachmentImageUrl = (att: AttachmentRecord): string | null => { + if (att.uploaded_url) return att.uploaded_url; + if (targetIdSet.has(att.message_id)) return att.discord_url; + return null; + }; const imageAttachments = (attachments || []) .filter( - (att) => - (att.uploaded_url || att.discord_url) && att.type.startsWith("image/"), + (att) => getAttachmentImageUrl(att) && att.type.startsWith("image/"), ) .sort((a, b) => { const aIsTarget = targetIdSet.has(a.message_id) ? 1 : 0; @@ -367,76 +412,62 @@ Return ONLY valid JSON, no other text.`; | string | Array<{ type: string; text?: string; image_url?: { url: string } }>; if (imageAttachments.length > 0) { - const contentParts: Array<{ - type: string; - text?: string; - image_url?: { url: string }; - }> = []; + const imageParts = await Promise.all( + imageAttachments.map(async (att) => { + try { + const urlToUse = getAttachmentImageUrl(att); + if (!urlToUse) return []; + log.info( + { attachmentId: att.id, url: urlToUse }, + "Downloading attachment for base64 encoding", + ); + const res = await fetch(urlToUse); + if (!res.ok) { + log.warn( + { attachmentId: att.id, status: res.status }, + "Failed to fetch attachment image", + ); + return []; + } - // Download and convert all images to base64 data URLs - for (const att of imageAttachments) { - try { - const urlToUse = att.uploaded_url || att.discord_url; - log.info( - { attachmentId: att.id, url: urlToUse }, - "Downloading attachment for base64 encoding", - ); - const res = await fetch(urlToUse); - if (res.ok) { const buffer = await res.arrayBuffer(); const base64Str = Buffer.from(buffer).toString("base64"); const dataUrl = `data:${att.type};base64,${base64Str}`; - contentParts.push({ - type: "image_url", - image_url: { - url: dataUrl, + return [ + { + type: "image_url", + image_url: { + url: dataUrl, + }, }, - }); - - contentParts.push({ - type: "text", - text: `\n[Image Attachment for Message ID: ${att.message_id}, Filename: ${att.filename}]`, - }); - } else { + { + type: "text", + text: `\n[Image Attachment for Message ID: ${att.message_id}, Filename: ${att.filename}]`, + }, + ]; + } catch (err) { log.warn( - { attachmentId: att.id, status: res.status }, - "Failed to fetch attachment image", + { + attachmentId: att.id, + error: err instanceof Error ? err.message : String(err), + }, + "Error base64 encoding attachment", ); + return []; } - } catch (err) { - log.warn( - { - attachmentId: att.id, - error: err instanceof Error ? err.message : String(err), - }, - "Error base64 encoding attachment", - ); - } - } + }), + ); - contentParts.push({ - type: "text", - text: prompt, - }); - - messageContent = contentParts; - } else { - // If no image is present, send a transparent 1x1 dummy PNG to satisfy multimodal omni requirements - const dummyPng = - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; messageContent = [ - { - type: "image_url", - image_url: { - url: dummyPng, - }, - }, + ...imageParts.flat(), { type: "text", - text: prompt, + text: userPrompt, }, ]; + } else { + messageContent = userPrompt; } const result = await retryWithBackoff( @@ -460,6 +491,10 @@ Return ONLY valid JSON, no other text.`; body: JSON.stringify({ model: config.AI_LLM_MODEL, messages: [ + { + role: "system", + content: systemPrompt, + }, { role: "user", content: messageContent, @@ -500,16 +535,10 @@ Return ONLY valid JSON, no other text.`; throw new Error("Empty LLM response"); } - // Try to parse the body as JSON, with fallback to scanning for an object try { return JSON.parse(rawBody); - } catch (e) { - const start = rawBody.indexOf("{"); - const end = rawBody.lastIndexOf("}"); - if (start !== -1 && end !== -1 && end > start) { - return JSON.parse(rawBody.substring(start, end + 1)); - } - throw e; + } catch { + return parseFirstJsonObject(rawBody); } } finally { clearTimeout(timeoutId); diff --git a/src/moderation/messageCapture.ts b/src/moderation/messageCapture.ts index 2fb067b..67957dc 100644 --- a/src/moderation/messageCapture.ts +++ b/src/moderation/messageCapture.ts @@ -134,7 +134,7 @@ export async function captureMessage( await insertAttachment(attachmentRecord); - // Initiate async upload to Picser (non-blocking, fire-and-forget) + // Initiate async upload (non-blocking, fire-and-forget) if (!isBacklog) { processAttachmentUpload( attachment.id, diff --git a/src/recorder/uploader.ts b/src/recorder/uploader.ts index 98b37f8..0843aad 100644 --- a/src/recorder/uploader.ts +++ b/src/recorder/uploader.ts @@ -1,22 +1,16 @@ import fs from "node:fs"; import path from "node:path"; +import { config } from "../config"; import { insertVoiceRecording, updateVoiceRecordingAsFailed, updateVoiceRecordingAsUploaded, } from "../database/voiceRecordingRepo"; import { createChildLogger } from "../logger"; -import { retryWithBackoff } from "../retry"; +import { uploadToTele } from "../uploader/teleUpload"; const logger = createChildLogger("recording-uploader"); -export interface UploadResponse { - download_url: string; - public_id: string; - file_name: string; - size_bytes: number; -} - /** * Uploads a recorded segment OGG file to external server and registers in database */ @@ -60,40 +54,16 @@ export async function uploadRecordingSegment(input: { }); // 2. Perform async upload with retry logic - const downloadUrl = await retryWithBackoff( - async () => { - const fileBuffer = fs.readFileSync(oggPath); - const fileBlob = new Blob([fileBuffer], { type: "audio/ogg" }); - const formData = new FormData(); - formData.append("file", fileBlob, fileName); - formData.append("fileName", fileName); - - const res = await fetch("https://upload.asepharyana.tech/api/upload", { - method: "POST", - headers: { - accept: "application/json", - }, - body: formData, - }); - - if (!res.ok) { - throw new Error(`Upload failed: Status ${res.status}`); - } - - const data = (await res.json()) as UploadResponse; - if (!data.download_url) { - throw new Error("Missing download_url in response"); - } - - return data.download_url; - }, - { - retries: 3, - minTimeout: 1000, - maxTimeout: 5000, - logger, - }, - ); + const fileBuffer = fs.readFileSync(oggPath); + const uploadResult = await uploadToTele({ + buffer: fileBuffer, + filename: fileName, + contentType: "audio/ogg", + uploadUrl: config.TELE_UPLOAD_URL, + retries: 3, + logger, + }); + const downloadUrl = uploadResult.url; // 3. Update DB to uploaded state await updateVoiceRecordingAsUploaded(id, downloadUrl, Date.now()); diff --git a/src/uploader/teleUpload.ts b/src/uploader/teleUpload.ts new file mode 100644 index 0000000..6309322 --- /dev/null +++ b/src/uploader/teleUpload.ts @@ -0,0 +1,85 @@ +import type { CustomLogger } from "../logger"; +import { retryWithBackoff } from "../retry"; + +export interface TeleUploadResponse { + download_url: string; + public_id?: string; + file_name?: string; + size_bytes?: number; +} + +export interface TeleUploadResult { + url: string; + publicId?: string; + filename?: string; + sizeBytes?: number; +} + +export function parseTeleUploadResponse( + response: TeleUploadResponse, +): TeleUploadResult { + if (!response.download_url) { + throw new Error("Missing download_url in response"); + } + + return { + url: response.download_url, + publicId: response.public_id, + filename: response.file_name, + sizeBytes: response.size_bytes, + }; +} + +export async function uploadToTele(input: { + buffer: Buffer; + filename: string; + contentType: string; + uploadUrl: string; + timeoutMs?: number; + retries: number; + logger: CustomLogger; +}): Promise { + const { + buffer, + filename, + contentType, + uploadUrl, + timeoutMs, + retries, + logger, + } = input; + + const response = await retryWithBackoff( + async () => { + const fileBlob = new Blob([new Uint8Array(buffer)], { + type: contentType, + }); + const formData = new FormData(); + formData.append("file", fileBlob, filename); + formData.append("fileName", filename); + + const res = await fetch(uploadUrl, { + method: "POST", + headers: { + accept: "application/json", + }, + body: formData, + ...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}), + }); + + if (!res.ok) { + throw new Error(`Upload failed: Status ${res.status}`); + } + + return (await res.json()) as TeleUploadResponse; + }, + { + retries, + minTimeout: 1000, + maxTimeout: 5000, + logger, + }, + ); + + return parseTeleUploadResponse(response); +} diff --git a/tests/config.test.ts b/tests/config.test.ts index a26e9fe..18f4ff1 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -28,6 +28,39 @@ describe("loadConfig", () => { expect(config.WEBSERVER_PORT).toBe(4000); expect(config.RECORDINGS_DIR).toBe("./recordings"); expect(config.NODE_ENV).toBe("test"); + expect(config.TELE_UPLOAD_URL).toBe( + "https://upload.asepharyana.tech/api/upload", + ); + expect(config.AI_ANALYSIS_DEBOUNCE_MS).toBe(500); + expect(config.AI_ANALYSIS_RECOVERY_INTERVAL_MS).toBe(15000); + expect(config.AI_ANALYSIS_ERROR_COOLDOWN_MS).toBe(30000); + expect(config.AI_ANALYSIS_MAX_BATCH_SIZE).toBe(25); + expect(config.AI_ANALYSIS_MAX_CONTEXT_TOKENS).toBe(8000); + expect(config.AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT).toBe(20); + }); + + it("coerces AI analysis tuning values", async () => { + process.env = { + ...originalEnv, + DISCORD_TOKEN: "token", + AI_ANALYSIS_DEBOUNCE_MS: "750", + AI_ANALYSIS_RECOVERY_INTERVAL_MS: "20000", + AI_ANALYSIS_ERROR_COOLDOWN_MS: "45000", + AI_ANALYSIS_MAX_BATCH_SIZE: "40", + AI_ANALYSIS_MAX_CONTEXT_TOKENS: "12000", + AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT: "35", + NODE_ENV: "test", + }; + + const { loadConfig } = await import("../src/config"); + const config = loadConfig(process.env); + + expect(config.AI_ANALYSIS_DEBOUNCE_MS).toBe(750); + expect(config.AI_ANALYSIS_RECOVERY_INTERVAL_MS).toBe(20000); + expect(config.AI_ANALYSIS_ERROR_COOLDOWN_MS).toBe(45000); + expect(config.AI_ANALYSIS_MAX_BATCH_SIZE).toBe(40); + expect(config.AI_ANALYSIS_MAX_CONTEXT_TOKENS).toBe(12000); + expect(config.AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT).toBe(35); }); it("derives split text and voice guild defaults from legacy config", async () => { diff --git a/tests/moderation/attachmentUploader.test.ts b/tests/moderation/attachmentUploader.test.ts index 2e1e7af..9986769 100644 --- a/tests/moderation/attachmentUploader.test.ts +++ b/tests/moderation/attachmentUploader.test.ts @@ -10,44 +10,31 @@ beforeEach(() => { }); describe("attachmentUploader", () => { - it("parses picser upload response correctly", async () => { - const { parseUploadResponse } = await import( - "../../src/moderation/attachmentUploader" + it("parses tele upload response correctly", async () => { + const { parseTeleUploadResponse } = await import( + "../../src/uploader/teleUpload" ); - const response = { - success: true, - filename: "uploads/abc123.jpg", - urls: { - raw_commit: - "https://raw.githubusercontent.com/user/repo/commit/uploads/abc123.jpg", - }, - size: 102400, - type: "image/jpeg", - }; + const result = parseTeleUploadResponse({ + download_url: "https://upload.asepharyana.tech/d/abc123.jpg", + public_id: "abc123", + file_name: "abc123.jpg", + size_bytes: 102400, + }); - const result = parseUploadResponse(response); - - expect(result.success).toBe(true); - expect(result.url).toBe( - "https://raw.githubusercontent.com/user/repo/commit/uploads/abc123.jpg", - ); - expect(result.filename).toBe("uploads/abc123.jpg"); + expect(result.url).toBe("https://upload.asepharyana.tech/d/abc123.jpg"); + expect(result.publicId).toBe("abc123"); + expect(result.filename).toBe("abc123.jpg"); + expect(result.sizeBytes).toBe(102400); }); - it("handles upload response with missing raw_commit", async () => { - const { parseUploadResponse } = await import( - "../../src/moderation/attachmentUploader" + it("handles upload response with missing download_url", async () => { + const { parseTeleUploadResponse } = await import( + "../../src/uploader/teleUpload" ); - const response = { - success: true, - filename: "uploads/abc123.jpg", - urls: {}, - size: 102400, - type: "image/jpeg", - }; - - expect(() => parseUploadResponse(response)).toThrow(); + expect(() => parseTeleUploadResponse({ download_url: "" })).toThrow( + /download_url/, + ); }); }); diff --git a/tests/moderation/llmModerationClient.test.ts b/tests/moderation/llmModerationClient.test.ts index 2e87979..0184d51 100644 --- a/tests/moderation/llmModerationClient.test.ts +++ b/tests/moderation/llmModerationClient.test.ts @@ -420,6 +420,46 @@ describe("runModerationAnalysis", () => { }); }); + it("sends text-only analysis without dummy image", async () => { + const mockResponse = { + choices: [ + { + message: { + content: JSON.stringify({ + results: [ + { + message_id: "m1", + status: "clean", + flags: [], + score: 0.1, + analysis: "OK", + }, + ], + }), + }, + }, + ], + }; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + text: async () => JSON.stringify(mockResponse), + json: async () => mockResponse, + }); + + await runModerationAnalysis({ + targets: [createMessageRecord()], + contextText: "test context", + }); + + const requestBody = JSON.parse((global.fetch as any).mock.calls[0][1].body); + expect(requestBody.messages[0].role).toBe("system"); + expect(requestBody.messages[1].role).toBe("user"); + expect(typeof requestBody.messages[1].content).toBe("string"); + expect(requestBody.messages[1].content).toContain("test context"); + expect(requestBody.messages[1].content).not.toContain("data:image/png"); + }); + it("throws on non-ok HTTP response", async () => { global.fetch = vi.fn().mockResolvedValue({ ok: false, @@ -435,6 +475,42 @@ describe("runModerationAnalysis", () => { ).rejects.toThrow(/LLM API error 500/); }); + it("parses first JSON object when provider appends extra JSON", async () => { + const mockResponse = { + choices: [ + { + message: { + content: JSON.stringify({ + results: [ + { + message_id: "m1", + status: "clean", + flags: [], + score: 0.1, + analysis: "OK", + }, + ], + }), + }, + }, + ], + }; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + text: async () => + `${JSON.stringify(mockResponse)}\n{"usage":{"tokens":12}}`, + }); + + const result = await runModerationAnalysis({ + targets: [createMessageRecord()], + contextText: "test context", + }); + + expect(result.results).toHaveLength(1); + expect(result.results[0].messageId).toBe("m1"); + }); + it("throws on missing choices in response", async () => { global.fetch = vi.fn().mockResolvedValue({ ok: true, @@ -544,7 +620,8 @@ describe("runModerationAnalysis", () => { const [, completionsOptions] = fetchCalls[1]; const body = JSON.parse(completionsOptions.body); - const userMessage = body.messages[0]; + expect(body.messages[0].role).toBe("system"); + const userMessage = body.messages[1]; expect(userMessage.role).toBe("user"); expect(Array.isArray(userMessage.content)).toBe(true); expect(userMessage.content[0].type).toBe("image_url"); @@ -556,7 +633,8 @@ describe("runModerationAnalysis", () => { "Image Attachment for Message ID: m1", ); expect(userMessage.content[2].type).toBe("text"); - expect(userMessage.content[2].text).toContain( + expect(userMessage.content[2].text).toContain("test context"); + expect(body.messages[0].content).toContain( "You are a content moderation assistant.", ); }); @@ -764,7 +842,7 @@ describe("runModerationAnalysis", () => { expect(fetchCalls[1][0]).toBe("https://httpbin.org/image/png"); const requestBody = JSON.parse(fetchCalls[2][1].body); - const contentParts = requestBody.messages[0].content; + const contentParts = requestBody.messages[1].content; expect( contentParts.filter((part: any) => part.type === "image_url"), ).toHaveLength(2); @@ -819,7 +897,7 @@ describe("runModerationAnalysis", () => { targets: [ createMessageRecord({ id: "m1", - content: "gambar belum selesai upload ke picser", + content: "gambar belum selesai upload ke tele", }), ], contextText: "test context", @@ -916,8 +994,8 @@ describe("runModerationAnalysis", () => { expect(result.results[0].status).toBe("warn"); const requestBody = JSON.parse((global.fetch as any).mock.calls[1][1].body); - expect(requestBody.messages[0].content).toHaveLength(1); - expect(requestBody.messages[0].content[0].text).toContain( + expect(requestBody.messages[1].content).toHaveLength(1); + expect(requestBody.messages[1].content[0].text).toContain( "https://example.invalid/claim", ); }); diff --git a/tests/moderation/messageStoreQueries.test.ts b/tests/moderation/messageStoreQueries.test.ts index 5d63e9a..eaafc4c 100644 --- a/tests/moderation/messageStoreQueries.test.ts +++ b/tests/moderation/messageStoreQueries.test.ts @@ -617,7 +617,7 @@ describe("message query integration tests", () => { size: 1024, type: "image/png", discord_url: "https://discord.com/test1.png", - uploaded_url: "https://picser.tech/test1.png", + uploaded_url: "https://upload.asepharyana.tech/test1.png", upload_status: "uploaded" as const, upload_error: null, created_at: Date.now(), @@ -635,7 +635,7 @@ describe("message query integration tests", () => { size: 2048, type: "image/png", discord_url: "https://discord.com/test2.png", - uploaded_url: "https://picser.tech/test2.png", + uploaded_url: "https://upload.asepharyana.tech/test2.png", upload_status: "uploaded" as const, upload_error: null, created_at: Date.now(),