feat(config): add AI analysis tuning parameters and update related logic

This commit is contained in:
MythEclipse
2026-05-21 01:55:50 +07:00
parent 5fb7b2ce24
commit 3d64228d6a
19 changed files with 644 additions and 293 deletions
+1 -5
View File
@@ -36,11 +36,7 @@ LOG_LEVEL=info
NODE_ENV=development NODE_ENV=development
# Moderation Configuration # Moderation Configuration
# TEXT_GUILD_ID falls back to legacy MONITOR_GUILD_ID when omitted. TELE_UPLOAD_URL=https://upload.asepharyana.tech/api/upload
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
ATTACHMENT_UPLOAD_TIMEOUT_MS=30000 ATTACHMENT_UPLOAD_TIMEOUT_MS=30000
ATTACHMENT_MAX_SIZE_MB=100 ATTACHMENT_MAX_SIZE_MB=100
ATTACHMENT_RETRY_ATTEMPTS=3 ATTACHMENT_RETRY_ATTEMPTS=3
+1 -2
View File
@@ -1,6 +1,5 @@
export default { export default {
plugins: { plugins: {
tailwindcss: {}, "@tailwindcss/postcss": {},
autoprefixer: {},
}, },
}; };
+7 -5
View File
@@ -1,6 +1,5 @@
@tailwind base; @import "tailwindcss";
@tailwind components; @config "../tailwind.config.js";
@tailwind utilities;
@layer base { @layer base {
:root { :root {
@@ -25,11 +24,14 @@
} }
* { * {
@apply border-border; border-color: hsl(var(--border));
} }
body { 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; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
} }
+1
View File
@@ -55,6 +55,7 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "latest", "@biomejs/biome": "latest",
"@tailwindcss/postcss": "^4.3.0",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/node": "^25.9.0", "@types/node": "^25.9.0",
+233 -11
View File
@@ -31,7 +31,7 @@ importers:
version: 8.20.0 version: 8.20.0
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^6.0.2 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: better-sqlite3:
specifier: ^12.10.0 specifier: ^12.10.0
version: 12.10.0 version: 12.10.0
@@ -85,7 +85,7 @@ importers:
version: 3.6.0 version: 3.6.0
vite: vite:
specifier: ^8.0.13 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: winston:
specifier: ^3.19.0 specifier: ^3.19.0
version: 3.19.0 version: 3.19.0
@@ -99,6 +99,9 @@ importers:
'@biomejs/biome': '@biomejs/biome':
specifier: latest specifier: latest
version: 2.4.15 version: 2.4.15
'@tailwindcss/postcss':
specifier: ^4.3.0
version: 4.3.0
'@types/better-sqlite3': '@types/better-sqlite3':
specifier: ^7.6.13 specifier: ^7.6.13
version: 7.6.13 version: 7.6.13
@@ -134,7 +137,7 @@ importers:
version: 4.22.2 version: 4.22.2
vitest: vitest:
specifier: latest 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: vendor/discord-video-stream:
dependencies: dependencies:
@@ -269,6 +272,10 @@ packages:
'@actions/io@1.1.3': '@actions/io@1.1.3':
resolution: {integrity: sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==} 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': '@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -1073,9 +1080,22 @@ packages:
resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} 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': '@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@jsdevtools/ez-spawn@3.0.4': '@jsdevtools/ez-spawn@3.0.4':
resolution: {integrity: sha512-f5DRIOZf7wxogefH03RjMPMdBF7ADTWUMoOs9kaJo06EfwF+aFhMZMDZxHg/Xe12hptN9xoZjGso2fdjapBRIA==} resolution: {integrity: sha512-f5DRIOZf7wxogefH03RjMPMdBF7ADTWUMoOs9kaJo06EfwF+aFhMZMDZxHg/Xe12hptN9xoZjGso2fdjapBRIA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1676,6 +1696,98 @@ packages:
'@standard-schema/spec@1.1.0': '@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} 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': '@tsd/typescript@5.9.3':
resolution: {integrity: sha512-JSSdNiS0wgd8GHhBwnMAI18Y8XPhLVN+dNelPfZCXFhy9Lb3NbnFyp9JKxxr54jSUkEJPk3cidvCoHducSaRMQ==} resolution: {integrity: sha512-JSSdNiS0wgd8GHhBwnMAI18Y8XPhLVN+dNelPfZCXFhy9Lb3NbnFyp9JKxxr54jSUkEJPk3cidvCoHducSaRMQ==}
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
@@ -2399,6 +2511,10 @@ packages:
end-of-stream@1.4.5: end-of-stream@1.4.5:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} 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: entities@2.1.0:
resolution: {integrity: sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==} resolution: {integrity: sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==}
@@ -2928,6 +3044,10 @@ packages:
resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} 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: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -3995,6 +4115,10 @@ packages:
tailwindcss@4.3.0: tailwindcss@4.3.0:
resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} 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: tar-fs@2.1.4:
resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==}
@@ -4517,6 +4641,8 @@ snapshots:
'@actions/io@1.1.3': {} '@actions/io@1.1.3': {}
'@alloc/quick-lru@5.2.0': {}
'@babel/code-frame@7.29.0': '@babel/code-frame@7.29.0':
dependencies: dependencies:
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
@@ -5023,8 +5149,25 @@ snapshots:
dependencies: dependencies:
'@sinclair/typebox': 0.27.10 '@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/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': '@jsdevtools/ez-spawn@3.0.4':
dependencies: dependencies:
call-me-maybe: 1.0.2 call-me-maybe: 1.0.2
@@ -5578,6 +5721,75 @@ snapshots:
'@standard-schema/spec@1.1.0': {} '@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': {} '@tsd/typescript@5.9.3': {}
'@tybys/wasm-util@0.10.2': '@tybys/wasm-util@0.10.2':
@@ -5693,10 +5905,10 @@ snapshots:
dependencies: dependencies:
'@types/node': 25.8.0 '@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: dependencies:
'@rolldown/pluginutils': 1.0.1 '@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': '@vitest/expect@4.1.6':
dependencies: dependencies:
@@ -5707,13 +5919,13 @@ snapshots:
chai: 6.2.2 chai: 6.2.2
tinyrainbow: 3.1.0 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: dependencies:
'@vitest/spy': 4.1.6 '@vitest/spy': 4.1.6
estree-walker: 3.0.3 estree-walker: 3.0.3
magic-string: 0.30.21 magic-string: 0.30.21
optionalDependencies: 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': '@vitest/pretty-format@4.1.6':
dependencies: dependencies:
@@ -6217,6 +6429,11 @@ snapshots:
dependencies: dependencies:
once: 1.4.0 once: 1.4.0
enhanced-resolve@5.21.5:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.3
entities@2.1.0: {} entities@2.1.0: {}
error-ex@1.3.4: error-ex@1.3.4:
@@ -6848,6 +7065,8 @@ snapshots:
jest-get-type@29.6.3: {} jest-get-type@29.6.3: {}
jiti@2.7.0: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
js-yaml@3.14.2: js-yaml@3.14.2:
@@ -7950,6 +8169,8 @@ snapshots:
tailwindcss@4.3.0: {} tailwindcss@4.3.0: {}
tapable@2.3.3: {}
tar-fs@2.1.4: tar-fs@2.1.4:
dependencies: dependencies:
chownr: 1.1.4 chownr: 1.1.4
@@ -8173,7 +8394,7 @@ snapshots:
vary@1.1.2: {} 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: dependencies:
lightningcss: 1.32.0 lightningcss: 1.32.0
picomatch: 4.0.4 picomatch: 4.0.4
@@ -8184,13 +8405,14 @@ snapshots:
'@types/node': 25.9.0 '@types/node': 25.9.0
esbuild: 0.28.0 esbuild: 0.28.0
fsevents: 2.3.3 fsevents: 2.3.3
jiti: 2.7.0
tsx: 4.22.2 tsx: 4.22.2
yaml: 2.9.0 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: dependencies:
'@vitest/expect': 4.1.6 '@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/pretty-format': 4.1.6
'@vitest/runner': 4.1.6 '@vitest/runner': 4.1.6
'@vitest/snapshot': 4.1.6 '@vitest/snapshot': 4.1.6
@@ -8207,7 +8429,7 @@ snapshots:
tinyexec: 1.1.2 tinyexec: 1.1.2
tinyglobby: 0.2.16 tinyglobby: 0.2.16
tinyrainbow: 3.1.0 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 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
+1 -1
View File
@@ -1,5 +1,5 @@
export default { export default {
plugins: { plugins: {
autoprefixer: {}, "@tailwindcss/postcss": {},
}, },
}; };
+15 -2
View File
@@ -37,10 +37,10 @@ const configSchema = z
.enum(["development", "production", "test"]) .enum(["development", "production", "test"])
.default("development"), .default("development"),
MONITOR_GUILD_ID: z.string().min(1).optional(), MONITOR_GUILD_ID: z.string().min(1).optional(),
PICSER_UPLOAD_URL: z TELE_UPLOAD_URL: z
.string() .string()
.url() .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_UPLOAD_TIMEOUT_MS: z.coerce.number().positive().default(30000),
ATTACHMENT_MAX_SIZE_MB: z.coerce.number().positive().default(100), ATTACHMENT_MAX_SIZE_MB: z.coerce.number().positive().default(100),
ATTACHMENT_RETRY_ATTEMPTS: z.coerce.number().positive().default(3), ATTACHMENT_RETRY_ATTEMPTS: z.coerce.number().positive().default(3),
@@ -69,6 +69,19 @@ const configSchema = z
.default("https://9router.asepharyana.tech/v1"), .default("https://9router.asepharyana.tech/v1"),
AI_LLM_MODEL: z.string().default("free"), AI_LLM_MODEL: z.string().default("free"),
AI_ANALYSIS_TIMEOUT_MS: z.coerce.number().positive().default(30000), 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_TYPE: z.enum(["sqlite", "postgres"]).default("sqlite"),
DATABASE_URL: z.string().optional(), DATABASE_URL: z.string().optional(),
POSTGRES_HOST: z.string().default("localhost"), POSTGRES_HOST: z.string().default("localhost"),
+3 -4
View File
@@ -1,4 +1,5 @@
import { parentPort } from "node:worker_threads"; import { parentPort } from "node:worker_threads";
import { config } from "../config.ts";
import { initializeDatabase } from "../database/drizzle.ts"; import { initializeDatabase } from "../database/drizzle.ts";
import { buildConversationPromptMessages } from "./conversationContext.ts"; import { buildConversationPromptMessages } from "./conversationContext.ts";
import { runModerationAnalysis } from "./llmModerationClient.ts"; import { runModerationAnalysis } from "./llmModerationClient.ts";
@@ -9,8 +10,6 @@ import {
} from "./messageStore.ts"; } from "./messageStore.ts";
import type { MessageRecord } from "./types"; import type { MessageRecord } from "./types";
const MAX_CONTEXT_TOKENS = 8000;
let dbInitialized = false; let dbInitialized = false;
interface AnalysisWorkerRequest { interface AnalysisWorkerRequest {
@@ -58,13 +57,13 @@ async function processAnalysisRequest({
channelId: firstMessage.channel_id, channelId: firstMessage.channel_id,
threadId: firstMessage.thread_id, threadId: firstMessage.thread_id,
beforeCreatedAt: firstMessage.created_at, beforeCreatedAt: firstMessage.created_at,
limit: 20, limit: config.AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT,
}); });
const promptMessages = buildConversationPromptMessages({ const promptMessages = buildConversationPromptMessages({
contextBefore, contextBefore,
targets: messages, targets: messages,
maxTokens: MAX_CONTEXT_TOKENS, maxTokens: config.AI_ANALYSIS_MAX_CONTEXT_TOKENS,
}); });
const targetIds = messages.map((m) => m.id); const targetIds = messages.map((m) => m.id);
+11 -11
View File
@@ -31,10 +31,6 @@ const conversationErrorCooldown = new Map<string, number>();
let activeRequests = 0; let activeRequests = 0;
let lastError: string | null = null; 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 { interface AnalysisWorkerResponse {
ok: boolean; ok: boolean;
@@ -98,7 +94,7 @@ async function processBatch(
lastError = result.error ?? "Analysis worker failed"; lastError = result.error ?? "Analysis worker failed";
conversationErrorCooldown.set( conversationErrorCooldown.set(
conversationKey, conversationKey,
Date.now() + ERROR_COOLDOWN_MS, Date.now() + config.AI_ANALYSIS_ERROR_COOLDOWN_MS,
); );
logger.error( logger.error(
{ {
@@ -106,7 +102,9 @@ async function processBatch(
error: lastError, error: lastError,
messageCount: messages.length, messageCount: messages.length,
messageIds: messages.map((m) => m.id), 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(), timestamp: new Date().toISOString(),
}, },
"Batch analysis failed, will retry after cooldown", "Batch analysis failed, will retry after cooldown",
@@ -120,7 +118,7 @@ async function processBatch(
const errorStack = error instanceof Error ? error.stack : undefined; const errorStack = error instanceof Error ? error.stack : undefined;
conversationErrorCooldown.set( conversationErrorCooldown.set(
conversationKey, conversationKey,
Date.now() + ERROR_COOLDOWN_MS, Date.now() + config.AI_ANALYSIS_ERROR_COOLDOWN_MS,
); );
logger.error( logger.error(
{ {
@@ -129,7 +127,9 @@ async function processBatch(
stack: errorStack, stack: errorStack,
messageCount: messages.length, messageCount: messages.length,
messageIds: messages.map((m) => m.id), 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(), timestamp: new Date().toISOString(),
}, },
"Analysis worker failed, will retry after cooldown", "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) // 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 // Set new debounced timer
const timer = setTimeout(async () => { const timer = setTimeout(async () => {
@@ -197,7 +197,7 @@ function scheduleConversationAnalysis(conversationKey: string): void {
// Get pending messages for this conversation // Get pending messages for this conversation
const messages = await getPendingMessagesByConversation( const messages = await getPendingMessagesByConversation(
conversationKey, conversationKey,
MAX_BATCH_SIZE, config.AI_ANALYSIS_MAX_BATCH_SIZE,
); );
if (messages.length > 0) { if (messages.length > 0) {
@@ -290,5 +290,5 @@ export function startPendingAIAnalysisWorker(): void {
} catch (error) { } catch (error) {
logger.error({ error }, "Pending AI analysis recovery worker failed"); logger.error({ error }, "Pending AI analysis recovery worker failed");
} }
}, RECOVERY_INTERVAL_MS); }, config.AI_ANALYSIS_RECOVERY_INTERVAL_MS);
} }
+15 -72
View File
@@ -1,6 +1,6 @@
import { config } from "../config"; import { config } from "../config";
import { createChildLogger } from "../logger"; import { createChildLogger } from "../logger";
import { retryWithBackoff } from "../retry"; import { uploadToTele } from "../uploader/teleUpload";
import { import {
updateAttachmentAsFailedUpload, updateAttachmentAsFailedUpload,
updateAttachmentAsUploaded, updateAttachmentAsUploaded,
@@ -8,83 +8,26 @@ import {
const logger = createChildLogger("attachment-uploader"); 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 { function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
} }
export interface PicserUploadResponse { export async function uploadAttachmentToTele(
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(
fileBuffer: Buffer, fileBuffer: Buffer,
filename: string, filename: string,
): Promise<ParsedUploadResponse> { ): Promise<string> {
const formData = new FormData();
const blob = new Blob([new Uint8Array(fileBuffer)], {
type: "application/octet-stream",
});
formData.append("file", blob, filename);
try { try {
const response = await retryWithBackoff(async () => { const result = await uploadToTele({
const res = await fetch(config.PICSER_UPLOAD_URL, { buffer: fileBuffer,
method: "POST", filename,
body: formData, contentType: "application/octet-stream",
signal: AbortSignal.timeout(config.ATTACHMENT_UPLOAD_TIMEOUT_MS), uploadUrl: config.TELE_UPLOAD_URL,
}); timeoutMs: config.ATTACHMENT_UPLOAD_TIMEOUT_MS,
retries: config.ATTACHMENT_RETRY_ATTEMPTS,
logger,
});
if (!res.ok) { return result.url;
throw new Error(`Upload failed with status ${res.status}`);
}
return res.json() as Promise<PicserUploadResponse>;
}, ATTACHMENT_UPLOAD_RETRY_OPTIONS);
return parseUploadResponse(response);
} catch (error) { } catch (error) {
logger.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) { } catch (error) {
const errorMsg = toErrorMessage(error); const errorMsg = toErrorMessage(error);
await updateAttachmentAsFailedUpload(attachmentId, errorMsg); await updateAttachmentAsFailedUpload(attachmentId, errorMsg);
+19 -25
View File
@@ -32,45 +32,39 @@ export function buildConversationPromptMessages(
): string[] { ): string[] {
const { contextBefore, targets, maxTokens } = input; const { contextBefore, targets, maxTokens } = input;
// Format all messages
const formatMessage = (msg: MessageRecord, label: string): string => { const formatMessage = (msg: MessageRecord, label: string): string => {
const content = msg.edited_content ?? msg.content; const content = msg.edited_content ?? msg.content;
const timestamp = formatTimestamp(msg.created_at); const timestamp = formatTimestamp(msg.created_at);
return `[${label}] id=${msg.id} time=${timestamp} user=${msg.username}: ${content}`; return `[${label}] id=${msg.id} time=${timestamp} user=${msg.username}: ${content}`;
}; };
const targetLines = targets.map((msg) => formatMessage(msg, "target")); const targetEntries = targets.map((msg) => ({
const contextLines = contextBefore.map((msg) => msg,
formatMessage(msg, "context"), label: "target" as const,
); line: formatMessage(msg, "target"),
}));
// Calculate tokens for targets (always include) let usedTokens = targetEntries.reduce(
let usedTokens = targetLines.reduce( (sum, entry) => sum + estimateTokens(entry.line),
(sum, line) => sum + estimateTokens(line),
0, 0,
); );
// Add context lines in reverse chronological order (most recent first) const selectedContextEntries: Array<{
// until we hit the token budget msg: MessageRecord;
const selectedContextLines: string[] = []; label: "context";
for (let i = contextLines.length - 1; i >= 0; i--) { line: string;
const line = contextLines[i]; }> = [];
for (let i = contextBefore.length - 1; i >= 0; i--) {
const msg = contextBefore[i];
const line = formatMessage(msg, "context");
const lineTokens = estimateTokens(line); const lineTokens = estimateTokens(line);
if (usedTokens + lineTokens <= maxTokens) { if (usedTokens + lineTokens <= maxTokens) {
selectedContextLines.unshift(line); // prepend to maintain chronological order selectedContextEntries.push({ msg, label: "context", line });
usedTokens += lineTokens; usedTokens += lineTokens;
} }
} }
// Combine: context (chronological) + targets (chronological) return [...selectedContextEntries, ...targetEntries]
const allMessages = [...selectedContextLines, ...targetLines]; .sort((a, b) => a.msg.created_at - b.msg.created_at)
.map((entry) => entry.line);
// 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;
} }
+101 -72
View File
@@ -17,6 +17,48 @@ interface RawModerationResponse {
results: RawModerationResult[]; 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. * 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. * 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); const targetIds = targets.map((t) => t.id);
// Build prompt
const messagesText = targets const messagesText = targets
.map((msg) => `[${msg.id}] ${msg.username}: ${msg.content}`) .map((msg) => `[${msg.id}] ${msg.username}: ${msg.content}`)
.join("\n"); .join("\n");
const prompt = `You are a content moderation assistant. Analyze the following messages for policy violations. const systemPrompt = `You are a content moderation assistant. Analyze messages for policy violations.
Context: ${contextText}
Messages to analyze:
${messagesText}
For each message, respond with a JSON object containing a "results" array. 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. 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. Do not include reasoning, analysis steps, markdown, prose, XML tags, or comments.
Return ONLY valid JSON, no other text.`; Return ONLY valid JSON, no other text.`;
const userPrompt = `Context: ${contextText}
Messages to analyze:
${messagesText}`;
// Check for image attachments to support multimodal analysis // Check for image attachments to support multimodal analysis
const targetIdSet = new Set(targets.map((t) => t.id)); 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 || []) const imageAttachments = (attachments || [])
.filter( .filter(
(att) => (att) => getAttachmentImageUrl(att) && att.type.startsWith("image/"),
(att.uploaded_url || att.discord_url) && att.type.startsWith("image/"),
) )
.sort((a, b) => { .sort((a, b) => {
const aIsTarget = targetIdSet.has(a.message_id) ? 1 : 0; const aIsTarget = targetIdSet.has(a.message_id) ? 1 : 0;
@@ -367,76 +412,62 @@ Return ONLY valid JSON, no other text.`;
| string | string
| Array<{ type: string; text?: string; image_url?: { url: string } }>; | Array<{ type: string; text?: string; image_url?: { url: string } }>;
if (imageAttachments.length > 0) { if (imageAttachments.length > 0) {
const contentParts: Array<{ const imageParts = await Promise.all(
type: string; imageAttachments.map(async (att) => {
text?: string; try {
image_url?: { url: string }; 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 buffer = await res.arrayBuffer();
const base64Str = Buffer.from(buffer).toString("base64"); const base64Str = Buffer.from(buffer).toString("base64");
const dataUrl = `data:${att.type};base64,${base64Str}`; const dataUrl = `data:${att.type};base64,${base64Str}`;
contentParts.push({ return [
type: "image_url", {
image_url: { type: "image_url",
url: dataUrl, image_url: {
url: dataUrl,
},
}, },
}); {
type: "text",
contentParts.push({ text: `\n[Image Attachment for Message ID: ${att.message_id}, Filename: ${att.filename}]`,
type: "text", },
text: `\n[Image Attachment for Message ID: ${att.message_id}, Filename: ${att.filename}]`, ];
}); } catch (err) {
} else {
log.warn( 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 = [ messageContent = [
{ ...imageParts.flat(),
type: "image_url",
image_url: {
url: dummyPng,
},
},
{ {
type: "text", type: "text",
text: prompt, text: userPrompt,
}, },
]; ];
} else {
messageContent = userPrompt;
} }
const result = await retryWithBackoff( const result = await retryWithBackoff(
@@ -460,6 +491,10 @@ Return ONLY valid JSON, no other text.`;
body: JSON.stringify({ body: JSON.stringify({
model: config.AI_LLM_MODEL, model: config.AI_LLM_MODEL,
messages: [ messages: [
{
role: "system",
content: systemPrompt,
},
{ {
role: "user", role: "user",
content: messageContent, content: messageContent,
@@ -500,16 +535,10 @@ Return ONLY valid JSON, no other text.`;
throw new Error("Empty LLM response"); throw new Error("Empty LLM response");
} }
// Try to parse the body as JSON, with fallback to scanning for an object
try { try {
return JSON.parse(rawBody); return JSON.parse(rawBody);
} catch (e) { } catch {
const start = rawBody.indexOf("{"); return parseFirstJsonObject(rawBody);
const end = rawBody.lastIndexOf("}");
if (start !== -1 && end !== -1 && end > start) {
return JSON.parse(rawBody.substring(start, end + 1));
}
throw e;
} }
} finally { } finally {
clearTimeout(timeoutId); clearTimeout(timeoutId);
+1 -1
View File
@@ -134,7 +134,7 @@ export async function captureMessage(
await insertAttachment(attachmentRecord); await insertAttachment(attachmentRecord);
// Initiate async upload to Picser (non-blocking, fire-and-forget) // Initiate async upload (non-blocking, fire-and-forget)
if (!isBacklog) { if (!isBacklog) {
processAttachmentUpload( processAttachmentUpload(
attachment.id, attachment.id,
+12 -42
View File
@@ -1,22 +1,16 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { config } from "../config";
import { import {
insertVoiceRecording, insertVoiceRecording,
updateVoiceRecordingAsFailed, updateVoiceRecordingAsFailed,
updateVoiceRecordingAsUploaded, updateVoiceRecordingAsUploaded,
} from "../database/voiceRecordingRepo"; } from "../database/voiceRecordingRepo";
import { createChildLogger } from "../logger"; import { createChildLogger } from "../logger";
import { retryWithBackoff } from "../retry"; import { uploadToTele } from "../uploader/teleUpload";
const logger = createChildLogger("recording-uploader"); 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 * 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 // 2. Perform async upload with retry logic
const downloadUrl = await retryWithBackoff( const fileBuffer = fs.readFileSync(oggPath);
async () => { const uploadResult = await uploadToTele({
const fileBuffer = fs.readFileSync(oggPath); buffer: fileBuffer,
const fileBlob = new Blob([fileBuffer], { type: "audio/ogg" }); filename: fileName,
const formData = new FormData(); contentType: "audio/ogg",
formData.append("file", fileBlob, fileName); uploadUrl: config.TELE_UPLOAD_URL,
formData.append("fileName", fileName); retries: 3,
logger,
const res = await fetch("https://upload.asepharyana.tech/api/upload", { });
method: "POST", const downloadUrl = uploadResult.url;
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,
},
);
// 3. Update DB to uploaded state // 3. Update DB to uploaded state
await updateVoiceRecordingAsUploaded(id, downloadUrl, Date.now()); await updateVoiceRecordingAsUploaded(id, downloadUrl, Date.now());
+85
View File
@@ -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<TeleUploadResult> {
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);
}
+33
View File
@@ -28,6 +28,39 @@ describe("loadConfig", () => {
expect(config.WEBSERVER_PORT).toBe(4000); expect(config.WEBSERVER_PORT).toBe(4000);
expect(config.RECORDINGS_DIR).toBe("./recordings"); expect(config.RECORDINGS_DIR).toBe("./recordings");
expect(config.NODE_ENV).toBe("test"); 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 () => { it("derives split text and voice guild defaults from legacy config", async () => {
+19 -32
View File
@@ -10,44 +10,31 @@ beforeEach(() => {
}); });
describe("attachmentUploader", () => { describe("attachmentUploader", () => {
it("parses picser upload response correctly", async () => { it("parses tele upload response correctly", async () => {
const { parseUploadResponse } = await import( const { parseTeleUploadResponse } = await import(
"../../src/moderation/attachmentUploader" "../../src/uploader/teleUpload"
); );
const response = { const result = parseTeleUploadResponse({
success: true, download_url: "https://upload.asepharyana.tech/d/abc123.jpg",
filename: "uploads/abc123.jpg", public_id: "abc123",
urls: { file_name: "abc123.jpg",
raw_commit: size_bytes: 102400,
"https://raw.githubusercontent.com/user/repo/commit/uploads/abc123.jpg", });
},
size: 102400,
type: "image/jpeg",
};
const result = parseUploadResponse(response); expect(result.url).toBe("https://upload.asepharyana.tech/d/abc123.jpg");
expect(result.publicId).toBe("abc123");
expect(result.success).toBe(true); expect(result.filename).toBe("abc123.jpg");
expect(result.url).toBe( expect(result.sizeBytes).toBe(102400);
"https://raw.githubusercontent.com/user/repo/commit/uploads/abc123.jpg",
);
expect(result.filename).toBe("uploads/abc123.jpg");
}); });
it("handles upload response with missing raw_commit", async () => { it("handles upload response with missing download_url", async () => {
const { parseUploadResponse } = await import( const { parseTeleUploadResponse } = await import(
"../../src/moderation/attachmentUploader" "../../src/uploader/teleUpload"
); );
const response = { expect(() => parseTeleUploadResponse({ download_url: "" })).toThrow(
success: true, /download_url/,
filename: "uploads/abc123.jpg", );
urls: {},
size: 102400,
type: "image/jpeg",
};
expect(() => parseUploadResponse(response)).toThrow();
}); });
}); });
+84 -6
View File
@@ -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 () => { it("throws on non-ok HTTP response", async () => {
global.fetch = vi.fn().mockResolvedValue({ global.fetch = vi.fn().mockResolvedValue({
ok: false, ok: false,
@@ -435,6 +475,42 @@ describe("runModerationAnalysis", () => {
).rejects.toThrow(/LLM API error 500/); ).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 () => { it("throws on missing choices in response", async () => {
global.fetch = vi.fn().mockResolvedValue({ global.fetch = vi.fn().mockResolvedValue({
ok: true, ok: true,
@@ -544,7 +620,8 @@ describe("runModerationAnalysis", () => {
const [, completionsOptions] = fetchCalls[1]; const [, completionsOptions] = fetchCalls[1];
const body = JSON.parse(completionsOptions.body); 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(userMessage.role).toBe("user");
expect(Array.isArray(userMessage.content)).toBe(true); expect(Array.isArray(userMessage.content)).toBe(true);
expect(userMessage.content[0].type).toBe("image_url"); expect(userMessage.content[0].type).toBe("image_url");
@@ -556,7 +633,8 @@ describe("runModerationAnalysis", () => {
"Image Attachment for Message ID: m1", "Image Attachment for Message ID: m1",
); );
expect(userMessage.content[2].type).toBe("text"); 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.", "You are a content moderation assistant.",
); );
}); });
@@ -764,7 +842,7 @@ describe("runModerationAnalysis", () => {
expect(fetchCalls[1][0]).toBe("https://httpbin.org/image/png"); expect(fetchCalls[1][0]).toBe("https://httpbin.org/image/png");
const requestBody = JSON.parse(fetchCalls[2][1].body); const requestBody = JSON.parse(fetchCalls[2][1].body);
const contentParts = requestBody.messages[0].content; const contentParts = requestBody.messages[1].content;
expect( expect(
contentParts.filter((part: any) => part.type === "image_url"), contentParts.filter((part: any) => part.type === "image_url"),
).toHaveLength(2); ).toHaveLength(2);
@@ -819,7 +897,7 @@ describe("runModerationAnalysis", () => {
targets: [ targets: [
createMessageRecord({ createMessageRecord({
id: "m1", id: "m1",
content: "gambar belum selesai upload ke picser", content: "gambar belum selesai upload ke tele",
}), }),
], ],
contextText: "test context", contextText: "test context",
@@ -916,8 +994,8 @@ describe("runModerationAnalysis", () => {
expect(result.results[0].status).toBe("warn"); expect(result.results[0].status).toBe("warn");
const requestBody = JSON.parse((global.fetch as any).mock.calls[1][1].body); const requestBody = JSON.parse((global.fetch as any).mock.calls[1][1].body);
expect(requestBody.messages[0].content).toHaveLength(1); expect(requestBody.messages[1].content).toHaveLength(1);
expect(requestBody.messages[0].content[0].text).toContain( expect(requestBody.messages[1].content[0].text).toContain(
"https://example.invalid/claim", "https://example.invalid/claim",
); );
}); });
+2 -2
View File
@@ -617,7 +617,7 @@ describe("message query integration tests", () => {
size: 1024, size: 1024,
type: "image/png", type: "image/png",
discord_url: "https://discord.com/test1.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_status: "uploaded" as const,
upload_error: null, upload_error: null,
created_at: Date.now(), created_at: Date.now(),
@@ -635,7 +635,7 @@ describe("message query integration tests", () => {
size: 2048, size: 2048,
type: "image/png", type: "image/png",
discord_url: "https://discord.com/test2.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_status: "uploaded" as const,
upload_error: null, upload_error: null,
created_at: Date.now(), created_at: Date.now(),