feat(config): add AI analysis tuning parameters and update related logic
This commit is contained in:
+1
-5
@@ -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,6 +1,5 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
"@tailwindcss/postcss": {},
|
||||||
autoprefixer: {},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+233
-11
@@ -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
@@ -1,5 +1,5 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
autoprefixer: {},
|
"@tailwindcss/postcss": {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+15
-2
@@ -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"),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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());
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user