+13
-6
.env.example
+13
-6
.env.example
···
2
2
ALLOWED_ORIGINS=
3
3
# Encryption secret for data stored in the Database (Like API keys)
4
4
API_KEY_ENCRYPTION_SECRET=
5
-
# Client ID found in the Oauth page of the Discord Developer Portal
5
+
# Discord bot credentials, can be found in https://discord.com/developers/applications
6
6
CLIENT_ID=
7
+
CLIENT_SECRET=
8
+
REDIRECT_URI=http://localhost:3000/api/auth/discord/callback
9
+
TOKEN=
7
10
# Postgresql database URLs
8
11
DATABASE_URL=
9
12
# Openrouter API key for the default AI model
10
13
OPENROUTER_API_KEY=
11
14
# OpenWeather API key for the weather command
12
15
OPENWEATHER_API_KEY=
16
+
# Massive.com API key for the /stocks command
17
+
MASSIVE_API_KEY=
18
+
# Optional override for the Massive.com REST base URL
19
+
MASSIVE_API_BASE_URL=https://api.massive.com
13
20
# Log level for the application (debug, info, warn, error)
14
21
LOG_LEVEL=info
15
22
# Node environment (development, production)
···
18
25
SOURCE_COMMIT=
19
26
# The API key going to be used for the status API
20
27
STATUS_API_KEY=
21
-
# The bot's token
22
-
TOKEN=
23
28
# Frontend envs
24
29
VITE_BOT_API_URL=
25
30
VITE_STATUS_API_KEY=
26
-
VITE_FRONTEND_URL=
27
-
VITE_DISCORD_CLIENT_ID=
28
-
29
31
# Deployment notification webhook
30
32
DEPLOYMENT_WEBHOOK_URL=
33
+
# Top.gg Webhook Configuration
34
+
TOPGG_WEBHOOK_SECRET=
35
+
TOPGG_WEBHOOK_AUTH=
36
+
# Top.gg token to check vote status
37
+
TOPGG_TOKEN=
+1
.github/FUNDING.yml
+1
.github/FUNDING.yml
···
1
+
ko_fi: scanash
+2
-2
.github/workflows/pr.yml
+2
-2
.github/workflows/pr.yml
···
8
8
jobs:
9
9
pr-checks:
10
10
name: PR Quality Checks
11
-
runs-on: self-hosted
11
+
runs-on: ubuntu-latest
12
12
permissions:
13
13
contents: read
14
14
pull-requests: read
···
70
70
71
71
size-check:
72
72
name: Bundle Size Check
73
-
runs-on: self-hosted
73
+
runs-on: ubuntu-latest
74
74
permissions:
75
75
contents: read
76
76
pull-requests: read
+2
-10
Dockerfile
+2
-10
Dockerfile
···
3
3
ARG SOURCE_COMMIT
4
4
ARG VITE_BOT_API_URL
5
5
ARG VITE_STATUS_API_KEY
6
-
ARG VITE_FRONTEND_URL
7
-
ARG VITE_DISCORD_CLIENT_ID
8
6
ARG STATUS_API_KEY
9
7
10
8
ENV SOURCE_COMMIT=${SOURCE_COMMIT}
11
9
ENV NODE_ENV=production
12
10
ENV VITE_BOT_API_URL=${VITE_BOT_API_URL}
13
11
ENV VITE_STATUS_API_KEY=${VITE_STATUS_API_KEY}
14
-
ENV VITE_FRONTEND_URL=${VITE_FRONTEND_URL}
15
-
ENV VITE_DISCORD_CLIENT_ID=${VITE_DISCORD_CLIENT_ID}
16
12
ENV STATUS_API_KEY=${STATUS_API_KEY}
17
13
18
14
WORKDIR /app
19
15
20
16
21
-
17
+
RUN apt-get update && apt-get install -y git fonts-dejavu-core fontconfig && rm -rf /var/lib/apt/lists/*
22
18
COPY package.json bun.lock ./
23
19
RUN bun install --frozen-lockfile
24
20
···
52
48
ARG SOURCE_COMMIT
53
49
ARG VITE_BOT_API_URL
54
50
ARG VITE_STATUS_API_KEY
55
-
ARG VITE_FRONTEND_URL
56
-
ARG VITE_DISCORD_CLIENT_ID
57
51
58
52
ENV SOURCE_COMMIT=${SOURCE_COMMIT}
59
53
ENV NODE_ENV=production
60
54
ENV VITE_BOT_API_URL=${VITE_BOT_API_URL}
61
55
ENV STATUS_API_KEY=${STATUS_API_KEY}
62
56
ENV VITE_STATUS_API_KEY=${VITE_STATUS_API_KEY}
63
-
ENV VITE_FRONTEND_URL=${VITE_FRONTEND_URL}
64
-
ENV VITE_DISCORD_CLIENT_ID=${VITE_DISCORD_CLIENT_ID}
65
57
66
-
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* && \
58
+
RUN apt-get update && apt-get install -y curl git fonts-dejavu-core fontconfig && rm -rf /var/lib/apt/lists/* && \
67
59
groupadd -g 1001 nodejs && \
68
60
useradd -r -u 1001 -g nodejs aethel
69
61
+1
-6
README.md
+1
-6
README.md
···
51
51
Run all SQL migrations:
52
52
53
53
```sh
54
-
bun run scripts/run-migration.js # or node scripts/run-migration.js
54
+
bun run scripts/run-migrations.js # or node scripts/run-migrations.js
55
55
```
56
56
57
57
---
···
86
86
This project is licensed under the MIT License.
87
87
88
88
See [LICENSE](LICENSE) for details.
89
-
90
-
7. Start the bot:
91
-
```bash
92
-
bun start
93
-
```
94
89
95
90
## Usage
96
91
+215
-50
bun.lock
+215
-50
bun.lock
···
4
4
"": {
5
5
"name": "aethel",
6
6
"dependencies": {
7
-
"@atproto/identity": "^0.4.8",
8
-
"@discordjs/rest": "^2.5.1",
9
-
"@fedify/fedify": "^1.1.0",
7
+
"@atproto/identity": "^0.4.9",
8
+
"@discordjs/rest": "^2.6.0",
9
+
"@fedify/fedify": "^1.8.12",
10
+
"@massive.com/client-js": "^9.0.0",
10
11
"@types/he": "^1.2.3",
11
12
"@types/sanitize-html": "^2.16.0",
12
-
"axios": "^1.11.0",
13
-
"city-timezones": "^1.3.1",
13
+
"axios": "^1.12.2",
14
+
"canvas": "^3.2.0",
15
+
"city-timezones": "^1.3.2",
16
+
"concurrently": "^9.2.1",
14
17
"cors": "^2.8.5",
15
-
"discord.js": "^14.21.0",
18
+
"discord.js": "^14.22.1",
16
19
"dotenv": "^16.6.1",
17
20
"eslint-plugin-prettier": "^5.5.4",
18
21
"express": "^4.21.2",
···
24
27
"moment-timezone": "^0.6.0",
25
28
"node-fetch": "^3.3.2",
26
29
"open-graph-scraper": "^6.10.0",
27
-
"openai": "^5.12.2",
30
+
"openai": "^5.23.1",
28
31
"pg": "^8.16.3",
29
32
"sanitize-html": "^2.17.0",
30
33
"uuid": "^11.1.0",
···
33
36
"winston": "^3.17.0",
34
37
},
35
38
"devDependencies": {
36
-
"@eslint/js": "^9.33.0",
39
+
"@eslint/js": "^9.36.0",
37
40
"@types/cors": "^2.8.19",
38
41
"@types/express": "^4.17.23",
39
42
"@types/jsonwebtoken": "^9.0.10",
40
-
"@types/node": "^24.2.1",
43
+
"@types/node": "^24.5.2",
41
44
"@types/open-graph-scraper": "^5.2.3",
42
45
"@types/pg": "^8.15.5",
43
46
"@types/uuid": "^10.0.0",
44
-
"@types/validator": "^13.15.2",
47
+
"@types/validator": "^13.15.3",
45
48
"@types/whois-json": "^2.0.4",
46
-
"eslint": "^9.33.0",
49
+
"eslint": "^9.36.0",
47
50
"eslint-config-prettier": "^10.1.8",
48
-
"globals": "^16.3.0",
51
+
"globals": "^16.4.0",
49
52
"nodemon": "^3.1.10",
50
53
"prettier": "^3.6.2",
51
54
"tsc-alias": "^1.8.16",
52
55
"tsconfig-paths": "^4.2.0",
53
-
"tsx": "^4.20.3",
56
+
"tsx": "^4.20.6",
54
57
"typescript": "^5.9.2",
55
-
"typescript-eslint": "^8.39.0",
58
+
"typescript-eslint": "^8.44.1",
56
59
},
57
60
},
58
61
},
59
62
"packages": {
60
-
"@atproto/common-web": ["@atproto/common-web@0.4.2", "", { "dependencies": { "graphemer": "^1.4.0", "multiformats": "^9.9.0", "uint8arrays": "3.0.0", "zod": "^3.23.8" } }, "sha512-vrXwGNoFGogodjQvJDxAeP3QbGtawgZute2ed1XdRO0wMixLk3qewtikZm06H259QDJVu6voKC5mubml+WgQUw=="],
63
+
"@atproto/common-web": ["@atproto/common-web@0.4.3", "", { "dependencies": { "graphemer": "^1.4.0", "multiformats": "^9.9.0", "uint8arrays": "3.0.0", "zod": "^3.23.8" } }, "sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg=="],
61
64
62
65
"@atproto/crypto": ["@atproto/crypto@0.4.4", "", { "dependencies": { "@noble/curves": "^1.7.0", "@noble/hashes": "^1.6.1", "uint8arrays": "3.0.0" } }, "sha512-Yq9+crJ7WQl7sxStVpHgie5Z51R05etaK9DLWYG/7bR5T4bhdcIgF6IfklLShtZwLYdVVj+K15s0BqW9a8PSDA=="],
63
66
64
-
"@atproto/identity": ["@atproto/identity@0.4.8", "", { "dependencies": { "@atproto/common-web": "^0.4.2", "@atproto/crypto": "^0.4.4" } }, "sha512-Z0sLnJ87SeNdAifT+rqpgE1Rc3layMMW25gfWNo4u40RGuRODbdfAZlTwBSU2r+Vk45hU+iE+xeQspfednCEnA=="],
67
+
"@atproto/identity": ["@atproto/identity@0.4.9", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/crypto": "^0.4.4" } }, "sha512-pRYCaeaEJMZ4vQlRQYYTrF3cMiRp21n/k/pUT1o7dgKby56zuLErDmFXkbKfKWPf7SgWRgamSaNmsGLqAOD7lQ=="],
65
68
66
69
"@cfworker/json-schema": ["@cfworker/json-schema@4.1.1", "", {}, "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="],
67
70
···
77
80
78
81
"@discordjs/formatters": ["@discordjs/formatters@0.6.1", "", { "dependencies": { "discord-api-types": "^0.38.1" } }, "sha512-5cnX+tASiPCqCWtFcFslxBVUaCetB0thvM/JyavhbXInP1HJIEU+Qv/zMrnuwSsX3yWH2lVXNJZeDK3EiP4HHg=="],
79
82
80
-
"@discordjs/rest": ["@discordjs/rest@2.5.1", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.1.1", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.3", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-Tg9840IneBcbrAjcGaQzHUJWFNq1MMWZjTdjJ0WS/89IffaNKc++iOvffucPxQTF/gviO9+9r8kEPea1X5J2Dw=="],
83
+
"@discordjs/rest": ["@discordjs/rest@2.6.0", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.1.1", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.3", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.16", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w=="],
81
84
82
85
"@discordjs/util": ["@discordjs/util@1.1.1", "", {}, "sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g=="],
83
86
···
135
138
136
139
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.8", "", { "os": "win32", "cpu": "x64" }, "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw=="],
137
140
138
-
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="],
141
+
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="],
139
142
140
143
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="],
141
144
···
147
150
148
151
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="],
149
152
150
-
"@eslint/js": ["@eslint/js@9.34.0", "", {}, "sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw=="],
153
+
"@eslint/js": ["@eslint/js@9.36.0", "", {}, "sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw=="],
151
154
152
155
"@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="],
153
156
···
155
158
156
159
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
157
160
158
-
"@fedify/fedify": ["@fedify/fedify@1.8.8", "", { "dependencies": { "@cfworker/json-schema": "^4.1.1", "@hugoalh/http-header-link": "^1.0.2", "@js-temporal/polyfill": "^0.5.1", "@logtape/logtape": "^1.0.0", "@multiformats/base-x": "^4.0.1", "@opentelemetry/api": "^1.9.0", "@opentelemetry/semantic-conventions": "^1.27.0", "@phensley/language-tag": "^1.9.0", "asn1js": "^3.0.5", "byte-encodings": "^1.0.11", "es-toolkit": "^1.39.5", "json-canon": "^1.0.1", "jsonld": "^8.3.2", "multicodec": "^3.2.1", "pkijs": "^3.2.4", "structured-field-values": "^2.0.4", "uri-template-router": "^0.0.17", "url-template": "^3.1.1", "urlpattern-polyfill": "^10.1.0" } }, "sha512-1w5YfKbh8wNbcJ1s0Ttb2l3i4oiWmQ7z5sbKqQJMhBW6odaGGVgEO2bl6ZI5Tg0i5o/LK3ODOKLTlr8F1wibHA=="],
161
+
"@fedify/fedify": ["@fedify/fedify@1.8.12", "", { "dependencies": { "@cfworker/json-schema": "^4.1.1", "@hugoalh/http-header-link": "^1.0.2", "@js-temporal/polyfill": "^0.5.1", "@logtape/logtape": "^1.0.0", "@multiformats/base-x": "^4.0.1", "@opentelemetry/api": "^1.9.0", "@opentelemetry/semantic-conventions": "^1.27.0", "@phensley/language-tag": "^1.9.0", "asn1js": "^3.0.5", "byte-encodings": "^1.0.11", "es-toolkit": "^1.39.5", "json-canon": "^1.0.1", "jsonld": "^8.3.2", "multicodec": "^3.2.1", "pkijs": "^3.2.4", "structured-field-values": "^2.0.4", "uri-template-router": "^0.0.17", "url-template": "^3.1.1", "urlpattern-polyfill": "^10.1.0" } }, "sha512-bdQIfXDtfzvuiftNarOxpd1VPa09lLzDj0bGXktBFEKKM/C5D0DQTSUr+1NHTP8PFgfIqhcCb93csywP9CsaOA=="],
159
162
160
163
"@hugoalh/http-header-link": ["@hugoalh/http-header-link@1.0.3", "", { "dependencies": { "@hugoalh/is-string-singleline": "^1.0.4" } }, "sha512-x4jzzKSzZQY115H/GxUWaAHzT5eqLXt99uSKY7+0O/h3XrV248+CkZA7cA274QahXzWkGQYYug/AF6QUkTnLEw=="],
161
164
···
173
176
174
177
"@logtape/logtape": ["@logtape/logtape@1.0.4", "", {}, "sha512-YvNVrXIxVpnY528zoiEjX8PqTfr0UCtKXyssvaWL8AE+OByFTCooKuKMdPlm6g65YUI9fPXrHn4UnogSskABnA=="],
175
178
179
+
"@massive.com/client-js": ["@massive.com/client-js@9.0.0", "", { "dependencies": { "axios": "^1.8.4", "cross-fetch": "^3.1.4", "query-string": "^7.0.1", "websocket": "^1.0.34" } }, "sha512-vfOSVMp7uIfFgsyyX58sMZvcwS67psOTbRTox+7ahKMsG7aztFTJfoBwmjj5EjkX4Ise1BB1OEh73fbGzsj+xA=="],
180
+
176
181
"@multiformats/base-x": ["@multiformats/base-x@4.0.1", "", {}, "sha512-eMk0b9ReBbV23xXU693TAIrLyeO5iTgBZGSJfpqriG8UkYvr/hC9u9pyMlAakDNHWmbhMZCDs6KQO0jzKD8OTw=="],
177
182
178
183
"@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="],
···
223
228
224
229
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
225
230
226
-
"@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="],
231
+
"@types/node": ["@types/node@24.5.2", "", { "dependencies": { "undici-types": "~7.12.0" } }, "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ=="],
227
232
228
233
"@types/open-graph-scraper": ["@types/open-graph-scraper@5.2.3", "", { "dependencies": { "open-graph-scraper": "*" } }, "sha512-R6ew1HJndBKsys2+Y10VW8yy3ojS7eF/mFXrOZSFxVqY7WI4ubxaFvgfaULnRn2pq149SpS2GZNB9i9Y5fQqEw=="],
229
234
···
243
248
244
249
"@types/uuid": ["@types/uuid@10.0.0", "", {}, "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ=="],
245
250
246
-
"@types/validator": ["@types/validator@13.15.2", "", {}, "sha512-y7pa/oEJJ4iGYBxOpfAKn5b9+xuihvzDVnC/OSvlVnGxVg0pOqmjiMafiJ1KVNQEaPZf9HsEp5icEwGg8uIe5Q=="],
251
+
"@types/validator": ["@types/validator@13.15.3", "", {}, "sha512-7bcUmDyS6PN3EuD9SlGGOxM77F8WLVsrwkxyWxKnxzmXoequ6c7741QBrANq6htVRGOITJ7z72mTP6Z4XyuG+Q=="],
247
252
248
253
"@types/whois-json": ["@types/whois-json@2.0.4", "", {}, "sha512-Pp5N/+A6LUE0FWXz6wQ2gV5wEw0uEqFBeSLuQAGdeTyRJv/bbz7PPj3H78jyulvQu7cnMpXTzKx4bo8TuPAYhw=="],
249
254
250
255
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
251
256
252
-
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.39.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.39.0", "@typescript-eslint/type-utils": "8.39.0", "@typescript-eslint/utils": "8.39.0", "@typescript-eslint/visitor-keys": "8.39.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.39.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw=="],
257
+
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.44.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.44.1", "@typescript-eslint/type-utils": "8.44.1", "@typescript-eslint/utils": "8.44.1", "@typescript-eslint/visitor-keys": "8.44.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.44.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-molgphGqOBT7t4YKCSkbasmu1tb1MgrZ2szGzHbclF7PNmOkSTQVHy+2jXOSnxvR3+Xe1yySHFZoqMpz3TfQsw=="],
253
258
254
-
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.39.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.39.0", "@typescript-eslint/types": "8.39.0", "@typescript-eslint/typescript-estree": "8.39.0", "@typescript-eslint/visitor-keys": "8.39.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg=="],
259
+
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.44.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.44.1", "@typescript-eslint/types": "8.44.1", "@typescript-eslint/typescript-estree": "8.44.1", "@typescript-eslint/visitor-keys": "8.44.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw=="],
255
260
256
-
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.39.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.39.0", "@typescript-eslint/types": "^8.39.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew=="],
261
+
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.44.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.44.1", "@typescript-eslint/types": "^8.44.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ycSa60eGg8GWAkVsKV4E6Nz33h+HjTXbsDT4FILyL8Obk5/mx4tbvCNsLf9zret3ipSumAOG89UcCs/KRaKYrA=="],
257
262
258
-
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.39.0", "", { "dependencies": { "@typescript-eslint/types": "8.39.0", "@typescript-eslint/visitor-keys": "8.39.0" } }, "sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A=="],
263
+
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.44.1", "", { "dependencies": { "@typescript-eslint/types": "8.44.1", "@typescript-eslint/visitor-keys": "8.44.1" } }, "sha512-NdhWHgmynpSvyhchGLXh+w12OMT308Gm25JoRIyTZqEbApiBiQHD/8xgb6LqCWCFcxFtWwaVdFsLPQI3jvhywg=="],
259
264
260
-
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.39.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ=="],
265
+
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.44.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-B5OyACouEjuIvof3o86lRMvyDsFwZm+4fBOqFHccIctYgBjqR3qT39FBYGN87khcgf0ExpdCBeGKpKRhSFTjKQ=="],
261
266
262
-
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.39.0", "", { "dependencies": { "@typescript-eslint/types": "8.39.0", "@typescript-eslint/typescript-estree": "8.39.0", "@typescript-eslint/utils": "8.39.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-6B3z0c1DXVT2vYA9+z9axjtc09rqKUPRmijD5m9iv8iQpHBRYRMBcgxSiKTZKm6FwWw1/cI4v6em35OsKCiN5Q=="],
267
+
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.44.1", "", { "dependencies": { "@typescript-eslint/types": "8.44.1", "@typescript-eslint/typescript-estree": "8.44.1", "@typescript-eslint/utils": "8.44.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-KdEerZqHWXsRNKjF9NYswNISnFzXfXNDfPxoTh7tqohU/PRIbwTmsjGK6V9/RTYWau7NZvfo52lgVk+sJh0K3g=="],
263
268
264
-
"@typescript-eslint/types": ["@typescript-eslint/types@8.39.0", "", {}, "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg=="],
269
+
"@typescript-eslint/types": ["@typescript-eslint/types@8.44.1", "", {}, "sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ=="],
265
270
266
-
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.39.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.39.0", "@typescript-eslint/tsconfig-utils": "8.39.0", "@typescript-eslint/types": "8.39.0", "@typescript-eslint/visitor-keys": "8.39.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw=="],
271
+
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.44.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.44.1", "@typescript-eslint/tsconfig-utils": "8.44.1", "@typescript-eslint/types": "8.44.1", "@typescript-eslint/visitor-keys": "8.44.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qnQJ+mVa7szevdEyvfItbO5Vo+GfZ4/GZWWDRRLjrxYPkhM+6zYB2vRYwCsoJLzqFCdZT4mEqyJoyzkunsZ96A=="],
267
272
268
-
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.39.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.39.0", "@typescript-eslint/types": "8.39.0", "@typescript-eslint/typescript-estree": "8.39.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ=="],
273
+
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.44.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.44.1", "@typescript-eslint/types": "8.44.1", "@typescript-eslint/typescript-estree": "8.44.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-DpX5Fp6edTlocMCwA+mHY8Mra+pPjRZ0TfHkXI8QFelIKcbADQz1LUPNtzOFUriBB2UYqw4Pi9+xV4w9ZczHFg=="],
269
274
270
-
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.39.0", "", { "dependencies": { "@typescript-eslint/types": "8.39.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA=="],
275
+
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.44.1", "", { "dependencies": { "@typescript-eslint/types": "8.44.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-576+u0QD+Jp3tZzvfRfxon0EA2lzcDt3lhUbsC6Lgzy9x2VR4E+JUiNyGHi5T8vk0TV+fpJ5GLG1JsJuWCaKhw=="],
271
276
272
277
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.6", "", {}, "sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA=="],
273
278
···
299
304
300
305
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
301
306
302
-
"axios": ["axios@1.11.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA=="],
307
+
"axios": ["axios@1.12.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw=="],
303
308
304
309
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
310
+
311
+
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
305
312
306
313
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
307
314
315
+
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
316
+
308
317
"body-parser": ["body-parser@1.20.3", "", { "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g=="],
309
318
310
319
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
···
313
322
314
323
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
315
324
325
+
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
326
+
316
327
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
328
+
329
+
"bufferutil": ["bufferutil@4.0.9", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw=="],
317
330
318
331
"byte-encodings": ["byte-encodings@1.0.11", "", {}, "sha512-+/xR2+ySc2yKGtud3DGkGSH1DNwHfRVK0KTnMhoeH36/KwG+tHQ4d9B3jxJFq7dW27YcfudkywaYJRPA2dmxzg=="],
319
332
···
333
346
334
347
"canonicalize": ["canonicalize@1.0.8", "", {}, "sha512-0CNTVCLZggSh7bc5VkX5WWPWO+cyZbNd07IHIsSXLia/eAq+r836hgk+8BKoEh7949Mda87VUOitx5OddVj64A=="],
335
348
349
+
"canvas": ["canvas@3.2.0", "", { "dependencies": { "node-addon-api": "^7.0.0", "prebuild-install": "^7.1.3" } }, "sha512-jk0GxrLtUEmW/TmFsk2WghvgHe8B0pxGilqCL21y8lHkPUGa6FTsnCNtHPOzT8O3y+N+m3espawV80bbBlgfTA=="],
350
+
336
351
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
337
352
338
353
"change-case": ["change-case@3.1.0", "", { "dependencies": { "camel-case": "^3.0.0", "constant-case": "^2.0.0", "dot-case": "^2.1.0", "header-case": "^1.0.0", "is-lower-case": "^1.1.0", "is-upper-case": "^1.1.0", "lower-case": "^1.1.1", "lower-case-first": "^1.0.0", "no-case": "^2.3.2", "param-case": "^2.1.0", "pascal-case": "^2.0.0", "path-case": "^2.1.0", "sentence-case": "^2.1.0", "snake-case": "^2.1.0", "swap-case": "^1.1.0", "title-case": "^2.1.0", "upper-case": "^1.1.1", "upper-case-first": "^1.1.0" } }, "sha512-2AZp7uJZbYEzRPsFoa+ijKdvp9zsrnnt6+yFokfwEpeJm0xuJDVoxiRCAaTzyJND8GJkofo2IcKWaUZ/OECVzw=="],
···
345
360
346
361
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
347
362
348
-
"city-timezones": ["city-timezones@1.3.1", "", { "dependencies": { "lodash": "^4.17.21" } }, "sha512-YCeJKGyw3DA+wV/oyuFuJlk4oqN9zkfLP+fz2nEXUBm9sW1xZaXQsKQoc8l8hP+vI45GPOq8OuGrlGXUcnLISA=="],
363
+
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
364
+
365
+
"city-timezones": ["city-timezones@1.3.2", "", { "dependencies": { "lodash": "^4.17.21" } }, "sha512-XztdL/2EWpfmgRIOzrKVOWFp6VdmaD9FNTZPINlez1etIn0mMNn01RMmSfOp6LUP/h1M2ZLX80N1O+WKwhzC+w=="],
349
366
350
-
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
367
+
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
351
368
352
369
"color": ["color@3.2.1", "", { "dependencies": { "color-convert": "^1.9.3", "color-string": "^1.6.0" } }, "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA=="],
353
370
···
365
382
366
383
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
367
384
385
+
"concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="],
386
+
368
387
"constant-case": ["constant-case@2.0.0", "", { "dependencies": { "snake-case": "^2.1.0", "upper-case": "^1.1.1" } }, "sha512-eS0N9WwmjTqrOmR3o83F5vW8Z+9R1HnVz3xmzT2PMFug9ly+Au/fxRWlEBSb6LcZwspSsEn9Xs1uw9YgzAg1EQ=="],
369
388
370
389
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
···
377
396
378
397
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
379
398
399
+
"cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
400
+
380
401
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
381
402
382
403
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
383
404
384
405
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
385
406
407
+
"d": ["d@1.0.2", "", { "dependencies": { "es5-ext": "^0.10.64", "type": "^2.7.2" } }, "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw=="],
408
+
386
409
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
387
410
388
411
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
389
412
390
413
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
391
414
415
+
"decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="],
416
+
417
+
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
418
+
392
419
"dedent-js": ["dedent-js@1.0.1", "", {}, "sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ=="],
420
+
421
+
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
393
422
394
423
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
395
424
···
401
430
402
431
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
403
432
433
+
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
434
+
404
435
"dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="],
405
436
406
437
"discord-api-types": ["discord-api-types@0.38.18", "", {}, "sha512-ygenySjZKUaBf5JT8BNhZSxLzwpwdp41O0wVroOTu/N2DxFH7dxYTZUSnFJ6v+/2F3BMcnD47PC47u4aLOLxrQ=="],
407
438
408
-
"discord.js": ["discord.js@14.21.0", "", { "dependencies": { "@discordjs/builders": "^1.11.2", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.1", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.1", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.1", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-U5w41cEmcnSfwKYlLv5RJjB8Joa+QJyRwIJz5i/eg+v2Qvv6EYpCRhN9I2Rlf0900LuqSDg8edakUATrDZQncQ=="],
439
+
"discord.js": ["discord.js@14.22.1", "", { "dependencies": { "@discordjs/builders": "^1.11.2", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.1", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.1.1", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.16", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-3k+Kisd/v570Jr68A1kNs7qVhNehDwDJAPe4DZ2Syt+/zobf9zEcuYFvsfIaAOgCa0BiHMfOOKQY4eYINl0z7w=="],
409
440
410
441
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
411
442
···
433
464
434
465
"encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="],
435
466
467
+
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
468
+
436
469
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
437
470
438
471
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
···
445
478
446
479
"es-toolkit": ["es-toolkit@1.39.10", "", {}, "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w=="],
447
480
481
+
"es5-ext": ["es5-ext@0.10.64", "", { "dependencies": { "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.3", "esniff": "^2.0.1", "next-tick": "^1.1.0" } }, "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg=="],
482
+
483
+
"es6-iterator": ["es6-iterator@2.0.3", "", { "dependencies": { "d": "1", "es5-ext": "^0.10.35", "es6-symbol": "^3.1.1" } }, "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g=="],
484
+
485
+
"es6-symbol": ["es6-symbol@3.1.4", "", { "dependencies": { "d": "^1.0.2", "ext": "^1.7.0" } }, "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg=="],
486
+
448
487
"esbuild": ["esbuild@0.25.8", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.8", "@esbuild/android-arm": "0.25.8", "@esbuild/android-arm64": "0.25.8", "@esbuild/android-x64": "0.25.8", "@esbuild/darwin-arm64": "0.25.8", "@esbuild/darwin-x64": "0.25.8", "@esbuild/freebsd-arm64": "0.25.8", "@esbuild/freebsd-x64": "0.25.8", "@esbuild/linux-arm": "0.25.8", "@esbuild/linux-arm64": "0.25.8", "@esbuild/linux-ia32": "0.25.8", "@esbuild/linux-loong64": "0.25.8", "@esbuild/linux-mips64el": "0.25.8", "@esbuild/linux-ppc64": "0.25.8", "@esbuild/linux-riscv64": "0.25.8", "@esbuild/linux-s390x": "0.25.8", "@esbuild/linux-x64": "0.25.8", "@esbuild/netbsd-arm64": "0.25.8", "@esbuild/netbsd-x64": "0.25.8", "@esbuild/openbsd-arm64": "0.25.8", "@esbuild/openbsd-x64": "0.25.8", "@esbuild/openharmony-arm64": "0.25.8", "@esbuild/sunos-x64": "0.25.8", "@esbuild/win32-arm64": "0.25.8", "@esbuild/win32-ia32": "0.25.8", "@esbuild/win32-x64": "0.25.8" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q=="],
488
+
489
+
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
449
490
450
491
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
451
492
452
493
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
453
494
454
-
"eslint": ["eslint@9.34.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.1", "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.34.0", "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg=="],
495
+
"eslint": ["eslint@9.36.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.1", "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.36.0", "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ=="],
455
496
456
497
"eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="],
457
498
···
460
501
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
461
502
462
503
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
504
+
505
+
"esniff": ["esniff@2.0.1", "", { "dependencies": { "d": "^1.0.1", "es5-ext": "^0.10.62", "event-emitter": "^0.3.5", "type": "^2.7.2" } }, "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg=="],
463
506
464
507
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
465
508
···
473
516
474
517
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
475
518
519
+
"event-emitter": ["event-emitter@0.3.5", "", { "dependencies": { "d": "1", "es5-ext": "~0.10.14" } }, "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA=="],
520
+
476
521
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
477
522
523
+
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
524
+
478
525
"express": ["express@4.21.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "0.19.0", "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA=="],
479
526
480
527
"express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
481
528
482
529
"express-validator": ["express-validator@7.2.1", "", { "dependencies": { "lodash": "^4.17.21", "validator": "~13.12.0" } }, "sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ=="],
530
+
531
+
"ext": ["ext@1.7.0", "", { "dependencies": { "type": "^2.7.2" } }, "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw=="],
483
532
484
533
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
485
534
···
500
549
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
501
550
502
551
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
552
+
553
+
"filter-obj": ["filter-obj@1.1.0", "", {}, "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ=="],
503
554
504
555
"finalhandler": ["finalhandler@1.3.1", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", "statuses": "2.0.1", "unpipe": "~1.0.0" } }, "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ=="],
505
556
···
521
572
522
573
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
523
574
575
+
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
576
+
524
577
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
525
578
526
579
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
···
533
586
534
587
"get-tsconfig": ["get-tsconfig@4.10.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ=="],
535
588
589
+
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
590
+
536
591
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
537
592
538
-
"globals": ["globals@16.3.0", "", {}, "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ=="],
593
+
"globals": ["globals@16.4.0", "", {}, "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw=="],
539
594
540
595
"globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="],
541
596
···
564
619
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
565
620
566
621
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
622
+
623
+
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
567
624
568
625
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
569
626
···
575
632
576
633
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
577
634
635
+
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
636
+
578
637
"ip-address": ["ip-address@9.0.5", "", { "dependencies": { "jsbn": "1.1.0", "sprintf-js": "^1.1.3" } }, "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g=="],
579
638
580
639
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
···
596
655
"is-plain-object": ["is-plain-object@5.0.0", "", {}, "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="],
597
656
598
657
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
658
+
659
+
"is-typedarray": ["is-typedarray@1.0.0", "", {}, "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA=="],
599
660
600
661
"is-upper-case": ["is-upper-case@1.1.2", "", { "dependencies": { "upper-case": "^1.1.0" } }, "sha512-GQYSJMgfeAmVwh9ixyk888l7OIhNAGKtY6QA+IrWlu9MDTCaXmeozOZ2S9Knj7bQwBO/H6J2kb+pbyTUiMNbsw=="],
601
662
···
685
746
686
747
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
687
748
749
+
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
750
+
688
751
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
689
752
690
753
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
754
+
755
+
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
691
756
692
757
"moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="],
693
758
···
703
768
704
769
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
705
770
771
+
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
772
+
706
773
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
707
774
708
775
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
709
776
777
+
"next-tick": ["next-tick@1.1.0", "", {}, "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="],
778
+
710
779
"no-case": ["no-case@2.3.2", "", { "dependencies": { "lower-case": "^1.1.1" } }, "sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ=="],
711
780
781
+
"node-abi": ["node-abi@3.80.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-LyPuZJcI9HVwzXK1GPxWNzrr+vr8Hp/3UqlmWxxh8p54U1ZbclOqbSog9lWHaCX+dBaiGi6n/hIX+mKu74GmPA=="],
782
+
783
+
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
784
+
712
785
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
713
786
714
787
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
715
788
789
+
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
790
+
716
791
"nodemon": ["nodemon@3.1.10", "", { "dependencies": { "chokidar": "^3.5.2", "debug": "^4", "ignore-by-default": "^1.0.1", "minimatch": "^3.1.2", "pstree.remy": "^1.1.8", "semver": "^7.5.3", "simple-update-notifier": "^2.0.0", "supports-color": "^5.5.0", "touch": "^3.1.0", "undefsafe": "^2.0.5" }, "bin": { "nodemon": "bin/nodemon.js" } }, "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw=="],
717
792
718
793
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
···
725
800
726
801
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
727
802
803
+
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
804
+
728
805
"one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="],
729
806
730
807
"open-graph-scraper": ["open-graph-scraper@6.10.0", "", { "dependencies": { "chardet": "^2.1.0", "cheerio": "^1.0.0-rc.12", "iconv-lite": "^0.6.3", "undici": "^6.21.2" } }, "sha512-JTuaO/mWUPduYCIQvunmsQnfGpSRFUTEh4k5cW2KOafJxTm3Z99z25/c1oO9QnIh2DK7ol5plJAq3EUVy+5xyw=="],
731
808
732
-
"openai": ["openai@5.16.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-hoEH8ZNvg1HXjU9mp88L/ZH8O082Z8r6FHCXGiWAzVRrEv443aI57qhch4snu07yQydj+AUAWLenAiBXhu89Tw=="],
809
+
"openai": ["openai@5.23.1", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-APxMtm5mln4jhKhAr0d5zP9lNsClx4QwJtg8RUvYSSyxYCTHLNJnLEcSHbJ6t0ori8Pbr9HZGfcPJ7LEy73rvQ=="],
733
810
734
811
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
735
812
···
799
876
800
877
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
801
878
879
+
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
880
+
802
881
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
803
882
804
883
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
···
811
890
812
891
"pstree.remy": ["pstree.remy@1.1.8", "", {}, "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w=="],
813
892
893
+
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
894
+
814
895
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
815
896
816
897
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
···
819
900
820
901
"qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="],
821
902
903
+
"query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="],
904
+
822
905
"queue-lit": ["queue-lit@1.5.2", "", {}, "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw=="],
823
906
824
907
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
···
827
910
828
911
"raw-body": ["raw-body@2.5.2", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "unpipe": "1.0.0" } }, "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA=="],
829
912
913
+
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
914
+
830
915
"rdf-canonize": ["rdf-canonize@3.4.0", "", { "dependencies": { "setimmediate": "^1.0.5" } }, "sha512-fUeWjrkOO0t1rg7B2fdyDTvngj+9RlUyL92vOdiB7c0FPguWVsniIMjEtHH+meLBO9rzkUlUzBVXgWrjI8P9LA=="],
831
916
832
917
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
···
844
929
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
845
930
846
931
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
932
+
933
+
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
847
934
848
935
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
849
936
···
871
958
872
959
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
873
960
961
+
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
962
+
874
963
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
875
964
876
965
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
···
878
967
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
879
968
880
969
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
970
+
971
+
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
972
+
973
+
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
881
974
882
975
"simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="],
883
976
···
893
986
894
987
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
895
988
989
+
"split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="],
990
+
896
991
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
897
992
898
993
"sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
···
900
995
"stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="],
901
996
902
997
"statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
998
+
999
+
"strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="],
903
1000
904
1001
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
905
1002
···
919
1016
920
1017
"synckit": ["synckit@0.11.11", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw=="],
921
1018
1019
+
"tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
1020
+
1021
+
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
1022
+
922
1023
"text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="],
923
1024
924
1025
"title-case": ["title-case@2.1.1", "", { "dependencies": { "no-case": "^2.2.0", "upper-case": "^1.0.3" } }, "sha512-EkJoZ2O3zdCz3zJsYCsxyq2OC5hrxR9mfdd5I+w8h/tmFfeOxJ+vvkxsKxdmN0WtS9zLdHEgfgVOiMVgv+Po4Q=="],
···
928
1029
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
929
1030
930
1031
"touch": ["touch@3.1.1", "", { "bin": { "nodetouch": "bin/nodetouch.js" } }, "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA=="],
1032
+
1033
+
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
1034
+
1035
+
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
931
1036
932
1037
"triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="],
933
1038
···
941
1046
942
1047
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
943
1048
944
-
"tsx": ["tsx@4.20.3", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ=="],
1049
+
"tsx": ["tsx@4.20.6", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg=="],
1050
+
1051
+
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
1052
+
1053
+
"type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="],
945
1054
946
1055
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
947
1056
948
1057
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
1058
+
1059
+
"typedarray-to-buffer": ["typedarray-to-buffer@3.1.5", "", { "dependencies": { "is-typedarray": "^1.0.0" } }, "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q=="],
949
1060
950
1061
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
951
1062
952
-
"typescript-eslint": ["typescript-eslint@8.39.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.39.0", "@typescript-eslint/parser": "8.39.0", "@typescript-eslint/typescript-estree": "8.39.0", "@typescript-eslint/utils": "8.39.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-lH8FvtdtzcHJCkMOKnN73LIn6SLTpoojgJqDAxPm1jCR14eWSGPX8ul/gggBdPMk/d5+u9V854vTYQ8T5jF/1Q=="],
1063
+
"typescript-eslint": ["typescript-eslint@8.44.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.44.1", "@typescript-eslint/parser": "8.44.1", "@typescript-eslint/typescript-estree": "8.44.1", "@typescript-eslint/utils": "8.44.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-0ws8uWGrUVTjEeN2OM4K1pLKHK/4NiNP/vz6ns+LjT/6sqpaYzIVFajZb1fj/IDwpsrrHb3Jy0Qm5u9CPcKaeg=="],
953
1064
954
1065
"uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="],
955
1066
···
959
1070
960
1071
"undici": ["undici@6.21.3", "", {}, "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw=="],
961
1072
962
-
"undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
1073
+
"undici-types": ["undici-types@7.12.0", "", {}, "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ=="],
963
1074
964
1075
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
965
1076
···
975
1086
976
1087
"urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="],
977
1088
1089
+
"utf-8-validate": ["utf-8-validate@5.0.10", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ=="],
1090
+
978
1091
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
979
1092
980
1093
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
···
989
1102
990
1103
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
991
1104
1105
+
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
1106
+
1107
+
"websocket": ["websocket@1.0.35", "", { "dependencies": { "bufferutil": "^4.0.1", "debug": "^2.2.0", "es5-ext": "^0.10.63", "typedarray-to-buffer": "^3.1.5", "utf-8-validate": "^5.0.2", "yaeti": "^0.0.6" } }, "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q=="],
1108
+
992
1109
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
993
1110
994
1111
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
1112
+
1113
+
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
995
1114
996
1115
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
997
1116
···
1007
1126
1008
1127
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
1009
1128
1010
-
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
1129
+
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
1130
+
1131
+
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
1011
1132
1012
1133
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
1013
1134
1014
1135
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
1015
1136
1016
-
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
1137
+
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
1138
+
1139
+
"yaeti": ["yaeti@0.0.6", "", {}, "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug=="],
1017
1140
1018
1141
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
1019
1142
1020
-
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
1143
+
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
1021
1144
1022
-
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
1145
+
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
1023
1146
1024
1147
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
1025
1148
···
1027
1150
1028
1151
"@digitalbazaar/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
1029
1152
1153
+
"@discordjs/ws/@discordjs/rest": ["@discordjs/rest@2.5.1", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.1.1", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.3", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-Tg9840IneBcbrAjcGaQzHUJWFNq1MMWZjTdjJ0WS/89IffaNKc++iOvffucPxQTF/gviO9+9r8kEPea1X5J2Dw=="],
1154
+
1030
1155
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
1031
1156
1032
1157
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
···
1069
1194
1070
1195
"color/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
1071
1196
1197
+
"concurrently/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
1198
+
1199
+
"cross-fetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
1200
+
1072
1201
"discord.js/@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="],
1073
1202
1074
1203
"express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
···
1083
1212
1084
1213
"raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
1085
1214
1215
+
"rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
1216
+
1086
1217
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
1087
1218
1088
1219
"send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="],
1089
1220
1090
-
"yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
1221
+
"websocket/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
1222
+
1223
+
"whois/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
1224
+
1225
+
"@types/body-parser/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
1226
+
1227
+
"@types/connect/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
1228
+
1229
+
"@types/cors/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
1230
+
1231
+
"@types/express-serve-static-core/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
1232
+
1233
+
"@types/jsonwebtoken/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
1234
+
1235
+
"@types/pg/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
1236
+
1237
+
"@types/send/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
1238
+
1239
+
"@types/serve-static/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
1240
+
1241
+
"@types/ws/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
1091
1242
1092
1243
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
1093
1244
···
1099
1250
1100
1251
"color/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
1101
1252
1253
+
"concurrently/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
1254
+
1102
1255
"express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
1103
1256
1104
1257
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
1105
1258
1106
1259
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
1107
1260
1108
-
"yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
1261
+
"websocket/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
1262
+
1263
+
"whois/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
1109
1264
1110
-
"yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
1265
+
"whois/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
1111
1266
1112
-
"yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
1267
+
"whois/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
1268
+
1269
+
"whois/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
1270
+
1271
+
"whois/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
1272
+
1273
+
"whois/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
1274
+
1275
+
"whois/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
1276
+
1277
+
"whois/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
1113
1278
}
1114
1279
}
+2
-2
docker-compose.example.yml
+2
-2
docker-compose.example.yml
···
6
6
args:
7
7
- VITE_BOT_API_URL=${VITE_BOT_API_URL:-https://aethel.xyz}
8
8
- VITE_STATUS_API_KEY=${VITE_STATUS_API_KEY}
9
-
- VITE_FRONTEND_URL=${VITE_FRONTEND_URL:-https://aethel.xyz}
10
-
- VITE_DISCORD_CLIENT_ID=${VITE_DISCORD_CLIENT_ID}
11
9
- STATUS_API_KEY=${STATUS_API_KEY}
12
10
- SOURCE_COMMIT=${SOURCE_COMMIT:-development}
13
11
container_name: aethel-bot
···
18
16
NODE_ENV: production
19
17
TOKEN: ${TOKEN}
20
18
CLIENT_ID: ${CLIENT_ID}
19
+
CLIENT_SECRET: ${CLIENT_SECRET}
20
+
REDIRECT_URI: ${REDIRECT_URI}
21
21
DATABASE_URL: ${DATABASE_URL}
22
22
API_KEY_ENCRYPTION_SECRET: ${API_KEY_ENCRYPTION_SECRET}
23
23
STATUS_API_KEY: ${STATUS_API_KEY}
+4
environment.d.ts
+4
environment.d.ts
···
6
6
DATABASE_URL: string;
7
7
OPENROUTER_API_KEY: string;
8
8
OPENWEATHER_API_KEY: string;
9
+
MASSIVE_API_KEY: string;
10
+
MASSIVE_API_BASE_URL: string;
9
11
SOURCE_COMMIT: string;
10
12
STATUS_API_KEY: string;
11
13
TOKEN: string;
12
14
CLIENT_ID: string;
15
+
CLIENT_SECRET: string;
16
+
REDIRECT_URI: string;
13
17
}
14
18
}
15
19
}
+2
-2
locales/de.json
+2
-2
locales/de.json
···
153
153
"modal": {
154
154
"title": "Gib deine API-Zugangsdaten ein",
155
155
"apikey": "API-Schlรผssel",
156
-
"apikeyplaceholder": "Um deinen Schlรผssel nicht mehr zu verwenden: /ai use_custom_api false",
156
+
"apikeyplaceholder": "Um deinen Schlรผssel nicht mehr zu verwenden: /ai custom_setup:false",
157
157
"apiurl": "API-URL",
158
158
"apiurlplaceholder": "Deine API-URL",
159
159
"model": "Modell",
160
160
"modelplaceholder": "Modellname (z.B. gpt-4)"
161
161
},
162
162
"nopendingrequest": "Keine ausstehende Anfrage gefunden. Bitte versuche den Befehl erneut.",
163
-
"apicredssaved": "API-Zugangsdaten gespeichert. Du kannst jetzt den Befehl `/ai` verwenden, ohne deine Zugangsdaten erneut einzugeben. Um deinen Schlรผssel nicht mehr zu verwenden, nutze `/ai use_custom_api false`",
163
+
"apicredssaved": "API-Zugangsdaten gespeichert. Du kannst jetzt den Befehl `/ai` verwenden, ohne deine Zugangsdaten erneut einzugeben. Um deinen Schlรผssel nicht mehr zu verwenden, nutze `/ai custom_setup:false`",
164
164
"process": {
165
165
"dailylimit": "Du hast dein tรคgliches Limit an KI-Anfragen erreicht",
166
166
"noapikey": "Bitte richte zuerst deinen API-Schlรผssel ein"
+52
-3
locales/en-US.json
+52
-3
locales/en-US.json
···
51
51
"newcat": "New cat",
52
52
"error": "Sorry, I had trouble fetching a cat image. Please try again later!"
53
53
},
54
+
"meow": {
55
+
"name": "meow",
56
+
"description": "Translate text into meow language",
57
+
"noText": "Please provide some text to translate to meow!",
58
+
"response": "๐ฑ {meowText}",
59
+
"options": {
60
+
"text": {
61
+
"name": "text",
62
+
"description": "The text to translate to meow"
63
+
}
64
+
}
65
+
},
54
66
"dog": {
55
67
"name": "dog",
56
68
"description": "Get a random dog image!",
···
80
92
"nolocation": "Location not found. Please check the city name and try again.",
81
93
"apikeymissing": "OpenWeather API key is missing or invalid."
82
94
},
95
+
"stocks": {
96
+
"name": "stocks",
97
+
"description": "Track stock prices and view quick charts",
98
+
"option": {
99
+
"ticker": {
100
+
"name": "ticker",
101
+
"description": "The stock ticker symbol (e.g., AAPL, TSLA)"
102
+
},
103
+
"range": {
104
+
"name": "range",
105
+
"description": "Initial timeframe for the chart"
106
+
}
107
+
},
108
+
"errors": {
109
+
"noapikey": "The Massive.com API key is missing. Ask the bot owner to configure MASSIVE_API_KEY.",
110
+
"notfound": "I couldn't find any data for {ticker}. Please double-check the symbol.",
111
+
"unauthorized": "Only the person who used /stocks can interact with these buttons."
112
+
},
113
+
"labels": {
114
+
"price": "Price",
115
+
"change": "Change",
116
+
"dayrange": "Day range",
117
+
"volume": "Volume",
118
+
"prevclose": "Prev close",
119
+
"marketcap": "Market cap",
120
+
"nochart": "Chart data unavailable for this timeframe."
121
+
},
122
+
"buttons": {
123
+
"timeframes": {
124
+
"1d": "1D",
125
+
"5d": "5D",
126
+
"1m": "1M",
127
+
"3m": "3M",
128
+
"1y": "1Y"
129
+
}
130
+
}
131
+
},
83
132
"joke": {
84
133
"name": "joke",
85
134
"description": "Get a random joke!",
···
162
211
"modal": {
163
212
"title": "Enter your API Credentials",
164
213
"apikey": "API Key",
165
-
"apikeyplaceholder": "To stop using your key: /ai use_custom_api false",
214
+
"apikeyplaceholder": "To stop using your key: /ai custom_setup:false",
166
215
"apiurl": "API Url",
167
216
"apiurlplaceholder": "Your API Url",
168
217
"model": "Model",
169
218
"modelplaceholder": "Model name (eg. gpt-4)"
170
219
},
171
220
"nopendingrequest": "No pending request found. Please try the command again.",
172
-
"apicredssaved": "API credentials saved. You can now use the `/ai` command without re-entering your credentials. To stop using your key, do `/ai use_custom_api false`",
221
+
"apicredssaved": "API credentials saved. You can now use the `/ai` command without re-entering your credentials. To stop using your key, do `/ai custom_setup:false`",
173
222
"testing": "Testing API key...",
174
223
"testfailed": "โ API key test failed: {error}. Please check your credentials and try again.",
175
224
"testsuccess": "โ
API key test successful! Credentials saved.",
176
225
"process": {
177
-
"dailylimit": "You've reached your daily limit of AI requests",
226
+
"dailylimit": "You've reached your daily limit of AI requests. Vote for Aethel to get more requests: https://top.gg/bot/1371031984230371369/vote\nOr set up your own API key using the \"/ai\" command.",
178
227
"noapikey": "Please set up your API key first"
179
228
},
180
229
"errors": {
+2
-2
locales/es-419.json
+2
-2
locales/es-419.json
···
153
153
"modal": {
154
154
"title": "Ingresa tus credenciales de API",
155
155
"apikey": "Clave API",
156
-
"apikeyplaceholder": "Para dejar de usar tu clave: /ai use_custom_api false",
156
+
"apikeyplaceholder": "Para dejar de usar tu clave: /ai custom_setup:false",
157
157
"apiurl": "URL de la API",
158
158
"apiurlplaceholder": "Tu URL de API",
159
159
"model": "Modelo",
160
160
"modelplaceholder": "Nombre del modelo (ej. gpt-4)"
161
161
},
162
162
"nopendingrequest": "No se encontrรณ ninguna solicitud pendiente. Por favor, intenta el comando de nuevo.",
163
-
"apicredssaved": "Credenciales de API guardadas. Ahora puedes usar el comando `/ai` sin volver a ingresar tus credenciales. Para dejar de usar tu clave, usa `/ai use_custom_api false`",
163
+
"apicredssaved": "Credenciales de API guardadas. Ahora puedes usar el comando `/ai` sin volver a ingresar tus credenciales. Para dejar de usar tu clave, usa `/ai custom_setup:false`",
164
164
"process": {
165
165
"dailylimit": "Has alcanzado tu lรญmite diario de solicitudes de IA",
166
166
"noapikey": "Por favor, configura primero tu clave API"
+2
-2
locales/es-ES.json
+2
-2
locales/es-ES.json
···
153
153
"modal": {
154
154
"title": "Introduce tus credenciales de API",
155
155
"apikey": "Clave API",
156
-
"apikeyplaceholder": "Para dejar de usar tu clave: /ai use_custom_api false",
156
+
"apikeyplaceholder": "Para dejar de usar tu clave: /ai custom_setup:false",
157
157
"apiurl": "URL de la API",
158
158
"apiurlplaceholder": "Tu URL de API",
159
159
"model": "Modelo",
160
160
"modelplaceholder": "Nombre del modelo (ej. gpt-4)"
161
161
},
162
162
"nopendingrequest": "No se encontrรณ ninguna solicitud pendiente. Por favor, intenta el comando de nuevo.",
163
-
"apicredssaved": "Credenciales de API guardadas. Ahora puedes usar el comando `/ai` sin volver a ingresar tus credenciales. Para dejar de usar tu clave, usa `/ai use_custom_api false`",
163
+
"apicredssaved": "Credenciales de API guardadas. Ahora puedes usar el comando `/ai` sin volver a ingresar tus credenciales. Para dejar de usar tu clave, usa `/ai custom_setup:false`",
164
164
"process": {
165
165
"dailylimit": "Has alcanzado tu lรญmite diario de solicitudes de IA",
166
166
"noapikey": "Por favor, configura primero tu clave API"
+2
-2
locales/fr.json
+2
-2
locales/fr.json
···
153
153
"modal": {
154
154
"title": "Entrez vos identifiants API",
155
155
"apikey": "Clรฉ API",
156
-
"apikeyplaceholder": "Pour ne plus utiliser votre clรฉ : /ai use_custom_api false",
156
+
"apikeyplaceholder": "Pour ne plus utiliser votre clรฉ : /ai custom_setup:false",
157
157
"apiurl": "URL de l'API",
158
158
"apiurlplaceholder": "Votre URL d'API",
159
159
"model": "Modรจle",
160
160
"modelplaceholder": "Nom du modรจle (ex. gpt-4)"
161
161
},
162
162
"nopendingrequest": "Aucune demande en attente trouvรฉe. Veuillez rรฉessayer la commande.",
163
-
"apicredssaved": "Identifiants API enregistrรฉs. Vous pouvez maintenant utiliser la commande `/ai` sans ressaisir vos identifiants. Pour ne plus utiliser votre clรฉ, faites `/ai use_custom_api false`",
163
+
"apicredssaved": "Identifiants API enregistrรฉs. Vous pouvez maintenant utiliser la commande `/ai` sans ressaisir vos identifiants. Pour ne plus utiliser votre clรฉ, faites `/ai custom_setup:false`",
164
164
"process": {
165
165
"dailylimit": "Vous avez atteint votre limite quotidienne de requรชtes IA",
166
166
"noapikey": "Veuillez d'abord configurer votre clรฉ API"
+4
-4
locales/ja.json
+4
-4
locales/ja.json
···
153
153
"modal": {
154
154
"title": "API่ช่จผๆ
ๅ ฑใๅ
ฅๅใใฆใใ ใใ",
155
155
"apikey": "APIใญใผ",
156
-
"apikeyplaceholder": "ใญใผใฎไฝฟ็จใใใใใซใฏ: /ai use_custom_api false",
157
-
"apiurl": "APIใฎURL",
156
+
"apikeyplaceholder": "ใญใผใฎไฝฟ็จใใใใใซใฏ: /ai custom_setup:false",
158
157
"apiurlplaceholder": "ใใชใใฎAPIใฎURL",
159
158
"model": "ใขใใซ",
160
-
"modelplaceholder": "ใขใใซๅ๏ผไพ: gpt-4๏ผ"
159
+
"modelplaceholder": "ใขใใซๅ๏ผไพ: gpt-4)",
160
+
"customsetup": "ใซในใฟใ ่จญๅฎ"
161
161
},
162
162
"nopendingrequest": "ไฟ็ไธญใฎใชใฏใจในใใ่ฆใคใใใพใใใใใไธๅบฆใณใใณใใใ่ฉฆใใใ ใใใ",
163
-
"apicredssaved": "API่ช่จผๆ
ๅ ฑใไฟๅญใใใพใใใใใใงๅๅ
ฅๅใใใซ`/ai`ใณใใณใใไฝฟใใพใใใญใผใฎไฝฟ็จใใใใใซใฏ `/ai use_custom_api false` ใไฝฟใฃใฆใใ ใใ",
163
+
"apicredssaved": "API่ช่จผๆ
ๅ ฑใไฟๅญใใใพใใใใใใง่ช่จผๆ
ๅ ฑใๅๅ
ฅๅใใใใจใชใ`/ai`ใณใใณใใไฝฟ็จใงใใพใใใญใผใฎไฝฟ็จใใใใใซใฏใ`/ai custom_setup:false`ใๅฎ่กใใฆใใ ใใ",
164
164
"process": {
165
165
"dailylimit": "AIใชใฏใจในใใฎ1ๆฅไธ้ใซ้ใใพใใ",
166
166
"noapikey": "ใพใAPIใญใผใ่จญๅฎใใฆใใ ใใ"
+2
-2
locales/pt-BR.json
+2
-2
locales/pt-BR.json
···
153
153
"modal": {
154
154
"title": "Insira suas credenciais de API",
155
155
"apikey": "Chave de API",
156
-
"apikeyplaceholder": "Para parar de usar sua chave: /ai use_custom_api false",
156
+
"apikeyplaceholder": "Para parar de usar sua chave: /ai custom_setup:false",
157
157
"apiurl": "URL da API",
158
158
"apiurlplaceholder": "Sua URL de API",
159
159
"model": "Modelo",
160
160
"modelplaceholder": "Nome do modelo (ex. gpt-4)"
161
161
},
162
162
"nopendingrequest": "Nenhuma solicitaรงรฃo pendente encontrada. Por favor, tente o comando novamente.",
163
-
"apicredssaved": "Credenciais de API salvas. Agora vocรช pode usar o comando `/ai` sem reinserir suas credenciais. Para parar de usar sua chave, use `/ai use_custom_api false`",
163
+
"apicredssaved": "Credenciais de API salvas. Agora vocรช pode usar o comando `/ai` sem reinserir suas credenciais. Para parar de usar sua chave, use `/ai custom_setup:false`",
164
164
"process": {
165
165
"dailylimit": "Vocรช atingiu seu limite diรกrio de solicitaรงรตes de IA",
166
166
"noapikey": "Por favor, configure sua chave de API primeiro"
+2
-2
locales/tr.json
+2
-2
locales/tr.json
···
153
153
"modal": {
154
154
"title": "API Kimlik Bilgilerinizi Girin",
155
155
"apikey": "API Anahtarฤฑ",
156
-
"apikeyplaceholder": "Kendi anahtarฤฑnฤฑzฤฑ kullanmayฤฑ bฤฑrakmak iรงin: /ai use_custom_api false",
156
+
"apikeyplaceholder": "Kendi anahtarฤฑnฤฑzฤฑ kullanmayฤฑ bฤฑrakmak iรงin: /ai custom_setup:false",
157
157
"apiurl": "API Adresi",
158
158
"apiurlplaceholder": "API Adresiniz",
159
159
"model": "Model",
160
160
"modelplaceholder": "Model adฤฑ (รถrn. gpt-4)"
161
161
},
162
162
"nopendingrequest": "Bekleyen bir istek bulunamadฤฑ. Lรผtfen komutu tekrar deneyin.",
163
-
"apicredssaved": "API kimlik bilgileri kaydedildi. Artฤฑk `/ai` komutunu anahtarฤฑnฤฑzฤฑ tekrar girmeden kullanabilirsiniz. Kendi anahtarฤฑnฤฑzฤฑ kullanmayฤฑ bฤฑrakmak iรงin /ai use_custom_api false yazฤฑn",
163
+
"apicredssaved": "API kimlik bilgileri kaydedildi. Artฤฑk `/ai` komutunu anahtarฤฑnฤฑzฤฑ tekrar girmeden kullanabilirsiniz. Kendi anahtarฤฑnฤฑzฤฑ kullanmayฤฑ bฤฑrakmak iรงin /ai custom_setup:false yazฤฑn",
164
164
"process": {
165
165
"dailylimit": "Gรผnlรผk AI istek limitinize ulaลtฤฑnฤฑz",
166
166
"noapikey": "Lรผtfen รถnce API anahtarฤฑnฤฑzฤฑ ayarlayฤฑn"
+23
migrations/010_create_voting_tables.sql
+23
migrations/010_create_voting_tables.sql
···
1
+
CREATE TABLE IF NOT EXISTS votes (
2
+
id SERIAL PRIMARY KEY,
3
+
user_id TEXT NOT NULL,
4
+
server_id TEXT,
5
+
vote_timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
6
+
claimed BOOLEAN DEFAULT FALSE,
7
+
credits_awarded INTEGER DEFAULT 10,
8
+
UNIQUE(user_id, server_id, vote_timestamp)
9
+
);
10
+
11
+
CREATE INDEX idx_votes_user ON votes(user_id, claimed);
12
+
CREATE INDEX idx_votes_server ON votes(server_id, claimed) WHERE server_id IS NOT NULL;
13
+
14
+
CREATE TABLE IF NOT EXISTS message_credits (
15
+
id SERIAL PRIMARY KEY,
16
+
user_id TEXT NOT NULL,
17
+
server_id TEXT,
18
+
credits_remaining INTEGER NOT NULL DEFAULT 0,
19
+
last_reset TIMESTAMP WITH TIME ZONE DEFAULT NOW()
20
+
);
21
+
22
+
CREATE UNIQUE INDEX idx_message_credits_user ON message_credits(user_id) WHERE server_id IS NULL;
23
+
CREATE UNIQUE INDEX idx_message_credits_server ON message_credits(user_id, server_id) WHERE server_id IS NOT NULL;
+20
-17
package.json
+20
-17
package.json
···
1
1
{
2
2
"name": "aethel",
3
-
"version": "2.0.1",
3
+
"version": "2.0.2",
4
4
"description": "A privacy-conscious, production-ready Discord user bot",
5
5
"type": "module",
6
6
"main": "dist/index.js",
7
7
"scripts": {
8
-
"start": "node ./dist/index.js",
8
+
"start": "node dist/index.js",
9
9
"dev": "tsx watch src/index.ts",
10
10
"build": "tsc && node scripts/fix-imports.js",
11
11
"migrate": "node scripts/run-migrations.js",
···
15
15
"lint:format": "eslint ./src ./web/src --ext .ts,.tsx --config eslint.config.cjs --format=codeframe",
16
16
"format": "prettier --write \"**/*.{js,json,md,ts,tsx}\" --ignore-path .prettierignore",
17
17
"format:check": "prettier --check \"**/*.{js,json,md,ts,tsx}\" --ignore-path .prettierignore",
18
-
"check": "pnpm run lint && pnpm run format:check"
18
+
"check": "bun run lint && bun run format:check"
19
19
},
20
20
"dependencies": {
21
-
"@atproto/identity": "^0.4.8",
22
-
"@discordjs/rest": "^2.5.1",
23
-
"@fedify/fedify": "^1.1.0",
21
+
"@atproto/identity": "^0.4.9",
22
+
"@discordjs/rest": "^2.6.0",
23
+
"@fedify/fedify": "^1.8.12",
24
+
"@massive.com/client-js": "^9.0.0",
24
25
"@types/he": "^1.2.3",
25
26
"@types/sanitize-html": "^2.16.0",
26
-
"axios": "^1.11.0",
27
-
"city-timezones": "^1.3.1",
27
+
"axios": "^1.12.2",
28
+
"canvas": "^3.2.0",
29
+
"city-timezones": "^1.3.2",
30
+
"concurrently": "^9.2.1",
28
31
"cors": "^2.8.5",
29
-
"discord.js": "^14.21.0",
32
+
"discord.js": "^14.22.1",
30
33
"dotenv": "^16.6.1",
31
34
"eslint-plugin-prettier": "^5.5.4",
32
35
"express": "^4.21.2",
···
38
41
"moment-timezone": "^0.6.0",
39
42
"node-fetch": "^3.3.2",
40
43
"open-graph-scraper": "^6.10.0",
41
-
"openai": "^5.12.2",
44
+
"openai": "^5.23.1",
42
45
"pg": "^8.16.3",
43
46
"sanitize-html": "^2.17.0",
44
47
"uuid": "^11.1.0",
···
47
50
"winston": "^3.17.0"
48
51
},
49
52
"devDependencies": {
50
-
"@eslint/js": "^9.33.0",
53
+
"@eslint/js": "^9.36.0",
51
54
"@types/cors": "^2.8.19",
52
55
"@types/express": "^4.17.23",
53
56
"@types/jsonwebtoken": "^9.0.10",
54
-
"@types/node": "^24.2.1",
57
+
"@types/node": "^24.5.2",
55
58
"@types/open-graph-scraper": "^5.2.3",
56
59
"@types/pg": "^8.15.5",
57
60
"@types/uuid": "^10.0.0",
58
-
"@types/validator": "^13.15.2",
61
+
"@types/validator": "^13.15.3",
59
62
"@types/whois-json": "^2.0.4",
60
-
"eslint": "^9.33.0",
63
+
"eslint": "^9.36.0",
61
64
"eslint-config-prettier": "^10.1.8",
62
-
"globals": "^16.3.0",
65
+
"globals": "^16.4.0",
63
66
"nodemon": "^3.1.10",
64
67
"prettier": "^3.6.2",
65
68
"tsc-alias": "^1.8.16",
66
69
"tsconfig-paths": "^4.2.0",
67
-
"tsx": "^4.20.3",
70
+
"tsx": "^4.20.6",
68
71
"typescript": "^5.9.2",
69
-
"typescript-eslint": "^8.39.0"
72
+
"typescript-eslint": "^8.44.1"
70
73
}
71
74
}
+1
-1
scripts/run-migrations.js
+1
-1
scripts/run-migrations.js
+1
-1
src/commands/fun/cat.ts
+1
-1
src/commands/fun/cat.ts
···
29
29
const commandLogger = createCommandLogger('cat');
30
30
const errorHandler = createErrorHandler('cat');
31
31
32
-
async function fetchCatImage(): Promise<RandomReddit> {
32
+
export async function fetchCatImage(): Promise<RandomReddit> {
33
33
const response = await fetch('https://api.pur.cat/random-cat'); //cat
34
34
if (!response.ok) {
35
35
throw new Error(`API request failed with status ${response.status}`);
+13
-7
src/commands/fun/dog.ts
+13
-7
src/commands/fun/dog.ts
···
31
31
const commandLogger = createCommandLogger('dog');
32
32
const errorHandler = createErrorHandler('dog');
33
33
34
-
async function fetchDogImage(): Promise<RandomReddit> {
34
+
export async function fetchDogImage(): Promise<RandomReddit> {
35
35
const response = await fetch('https://api.erm.dog/random-dog', { headers: browserHeaders });
36
36
if (!response.ok) {
37
37
throw new Error(`API request failed with status ${response.status}`);
38
38
}
39
-
return (await response.json()) as RandomReddit;
39
+
const data = (await response.json()) as RandomReddit;
40
+
return data;
40
41
}
41
42
42
43
export default {
···
58
59
])
59
60
.setIntegrationTypes(ApplicationIntegrationType.UserInstall),
60
61
61
-
async execute(client, interaction) {
62
+
execute: async (client, interaction) => {
62
63
try {
63
64
const cooldownCheck = await checkCooldown(
64
65
cooldownManager,
···
110
111
),
111
112
);
112
113
113
-
await interaction.editReply({
114
-
components: [container],
115
-
flags: MessageFlags.IsComponentsV2,
116
-
});
114
+
try {
115
+
await interaction.editReply({
116
+
components: [container],
117
+
flags: MessageFlags.IsComponentsV2,
118
+
});
119
+
} catch (replyError) {
120
+
logger.error('Failed to send reply:', replyError);
121
+
throw replyError;
122
+
}
117
123
} catch (error) {
118
124
await errorHandler({
119
125
interaction,
+154
src/commands/fun/meow.ts
+154
src/commands/fun/meow.ts
···
1
+
import {
2
+
SlashCommandBuilder,
3
+
ApplicationIntegrationType,
4
+
InteractionContextType,
5
+
} from 'discord.js';
6
+
import { SlashCommandProps } from '@/types/command';
7
+
import { createCommandLogger } from '@/utils/commandLogger';
8
+
import { createErrorHandler } from '@/utils/errorHandler';
9
+
import { sanitizeInput } from '@/utils/validation';
10
+
import {
11
+
createCooldownManager,
12
+
checkCooldown,
13
+
setCooldown,
14
+
createCooldownResponse,
15
+
} from '@/utils/cooldown';
16
+
import BotClient from '@/services/Client';
17
+
18
+
const cooldownManager = createCooldownManager('meow', 2000);
19
+
const commandLogger = createCommandLogger('meow');
20
+
const errorHandler = createErrorHandler('meow');
21
+
22
+
function translateToMeow(text: string): string {
23
+
const words = text.split(/\s+/);
24
+
const meowWords = words.map((word) => {
25
+
if (word.match(/^https?:\/\//) || word.match(/^<[@#]!?\d+>/) || word.match(/^:[^\s:]+:$/)) {
26
+
return word;
27
+
}
28
+
29
+
const punctuation = word.match(/[^\w\s]|_/g)?.join('') || '';
30
+
const cleanWord = word.replace(/[^\w\s]|_/g, '');
31
+
32
+
if (!cleanWord) return word;
33
+
34
+
const meowVariants = [
35
+
'meow',
36
+
'meow~',
37
+
'mew',
38
+
'mrrp',
39
+
'mew!',
40
+
'nya~',
41
+
'mraow',
42
+
'mrrrow',
43
+
'mewo',
44
+
];
45
+
const randomMeow = meowVariants[Math.floor(Math.random() * meowVariants.length)];
46
+
47
+
const firstChar = cleanWord[0];
48
+
const meowed =
49
+
firstChar === firstChar.toUpperCase()
50
+
? randomMeow.charAt(0).toUpperCase() + randomMeow.slice(1)
51
+
: randomMeow;
52
+
53
+
return meowed + punctuation;
54
+
});
55
+
56
+
return meowWords.join(' ');
57
+
}
58
+
59
+
export default {
60
+
data: new SlashCommandBuilder()
61
+
.setName('meow')
62
+
.setNameLocalizations({
63
+
'es-ES': 'maullar',
64
+
'pt-BR': 'miau',
65
+
'en-US': 'meow',
66
+
})
67
+
.setDescription('Translate text into meow language')
68
+
.setDescriptionLocalizations({
69
+
'es-ES': 'Traduce texto al idioma de los gatos',
70
+
'pt-BR': 'Traduz texto para a linguagem dos gatos',
71
+
'en-US': 'Translate text into meow language',
72
+
})
73
+
.addStringOption((option) =>
74
+
option
75
+
.setName('text')
76
+
.setNameLocalizations({
77
+
'es-ES': 'texto',
78
+
'pt-BR': 'texto',
79
+
'en-US': 'text',
80
+
})
81
+
.setDescription('The text to translate to meow')
82
+
.setDescriptionLocalizations({
83
+
'es-ES': 'El texto a traducir a maullidos',
84
+
'pt-BR': 'O texto para traduzir para miau',
85
+
'en-US': 'The text to translate to meow',
86
+
})
87
+
.setRequired(true)
88
+
.setMaxLength(1000),
89
+
)
90
+
.setContexts([
91
+
InteractionContextType.BotDM,
92
+
InteractionContextType.Guild,
93
+
InteractionContextType.PrivateChannel,
94
+
])
95
+
.setIntegrationTypes(ApplicationIntegrationType.UserInstall),
96
+
97
+
async execute(client: BotClient, interaction: import('discord.js').ChatInputCommandInteraction) {
98
+
try {
99
+
const cooldownCheck = await checkCooldown(
100
+
cooldownManager,
101
+
interaction.user.id,
102
+
client,
103
+
interaction.guildId || '',
104
+
);
105
+
106
+
if (cooldownCheck.onCooldown) {
107
+
await interaction.reply(
108
+
createCooldownResponse(
109
+
cooldownCheck.message || 'Please wait before using this command again.',
110
+
),
111
+
);
112
+
return;
113
+
}
114
+
115
+
await interaction.deferReply();
116
+
117
+
const text = interaction.options.getString('text', true);
118
+
const sanitizedText = sanitizeInput(text);
119
+
120
+
if (!sanitizedText) {
121
+
const noTextMessage = await client.getLocaleText(
122
+
'commands.meow.noText',
123
+
interaction.locale,
124
+
);
125
+
await interaction.editReply(noTextMessage);
126
+
return;
127
+
}
128
+
129
+
const meowText = translateToMeow(sanitizedText);
130
+
131
+
const response = await client.getLocaleText('commands.meow.response', interaction.locale, {
132
+
meowText,
133
+
});
134
+
135
+
await interaction.editReply({
136
+
content: response,
137
+
allowedMentions: { parse: [] },
138
+
});
139
+
140
+
setCooldown(cooldownManager, interaction.user.id);
141
+
commandLogger.logAction({
142
+
additionalInfo: `Text: ${sanitizedText}`,
143
+
});
144
+
} catch (error) {
145
+
await errorHandler({
146
+
interaction,
147
+
client,
148
+
error: error as Error,
149
+
userId: interaction.user.id,
150
+
username: interaction.user.username,
151
+
});
152
+
}
153
+
},
154
+
} as SlashCommandProps;
+50
-3
src/commands/fun/trivia.ts
+50
-3
src/commands/fun/trivia.ts
···
40
40
queueOpen: boolean;
41
41
originalQuestionCount: number;
42
42
currentShuffledAnswers: string[];
43
+
messageId?: string;
43
44
}
44
45
45
46
const gameManager = createMemoryManager<string, GameSession>({
···
50
51
51
52
const commandLogger = createCommandLogger('trivia');
52
53
const errorHandler = createErrorHandler('trivia');
54
+
55
+
function saveSession(session: GameSession) {
56
+
gameManager.set(session.channelId, session);
57
+
if (session.messageId) gameManager.set(session.messageId, session);
58
+
}
53
59
54
60
function shuffleArray<T>(array: T[]): T[] {
55
61
const shuffled = [...array];
···
269
275
session.queueOpen = false;
270
276
session.currentQuestionIndex = 0;
271
277
session.currentPlayer = Array.from(session.players)[0];
278
+
saveSession(session);
272
279
273
280
await askQuestion(interaction, session, client);
274
281
} catch {
···
324
331
const question = session.questions[session.currentQuestionIndex];
325
332
const answers = shuffleArray([question.correct_answer, ...question.incorrect_answers]);
326
333
session.currentShuffledAnswers = answers;
334
+
saveSession(session);
327
335
const questionId = `${session.channelId}_${session.currentQuestionIndex}`;
328
336
329
337
const playerMention = `<@${session.currentPlayer}>`;
···
392
400
});
393
401
394
402
gameManager.delete(session.channelId);
403
+
if (session.messageId) gameManager.delete(session.messageId);
395
404
}
396
405
397
406
const triviaCommand = {
···
475
484
) => {
476
485
try {
477
486
const channelId = interaction.channelId;
478
-
const session = gameManager.get(channelId);
487
+
const clickMessageId = interaction.message?.id;
488
+
let session =
489
+
(clickMessageId && gameManager.get(clickMessageId)) || gameManager.get(channelId);
490
+
const customId = interaction.customId;
491
+
492
+
if (!session && clickMessageId) {
493
+
for (const [, s] of gameManager.entries()) {
494
+
if (s.messageId === clickMessageId) {
495
+
session = s;
496
+
break;
497
+
}
498
+
}
499
+
}
500
+
501
+
if (customId.startsWith('trivia_answer_')) {
502
+
const parts = customId.split('_');
503
+
if (parts.length >= 5) {
504
+
const embeddedChannelId = parts[2];
505
+
if (embeddedChannelId && (!session || session.channelId !== embeddedChannelId)) {
506
+
const byEmbedded = gameManager.get(embeddedChannelId);
507
+
if (byEmbedded) {
508
+
session = byEmbedded;
509
+
}
510
+
}
511
+
}
512
+
}
479
513
480
514
if (!session) {
481
515
const errorMsg = await client.getLocaleText(
···
488
522
});
489
523
}
490
524
491
-
const customId = interaction.customId;
525
+
if (!session) {
526
+
const parts = customId.split('_');
527
+
const embeddedChannelId = parts.length >= 5 ? parts[2] : 'n/a';
528
+
const errorMsg = await client.getLocaleText(
529
+
'commands.trivia.messages.no_active_game',
530
+
interaction.locale,
531
+
);
532
+
const diag = `\n[diag] ch:${channelId} emb:${embeddedChannelId} msg:${clickMessageId ?? 'n/a'}`;
533
+
return interaction.reply({ content: errorMsg + diag, flags: MessageFlags.Ephemeral });
534
+
}
492
535
493
536
if (customId === 'trivia_join') {
494
537
if (!session.queueOpen) {
···
515
558
516
559
session.players.add(interaction.user.id);
517
560
session.scores.set(interaction.user.id, 0);
561
+
saveSession(session);
518
562
519
563
const playersList = Array.from(session.players)
520
564
.map((id) => `โข <@${id}>`)
···
577
621
}
578
622
579
623
gameManager.delete(channelId);
624
+
if (session.messageId) gameManager.delete(session.messageId);
580
625
581
626
const cancelMsg = await client.getLocaleText(
582
627
'commands.trivia.messages.game_cancelled',
···
587
632
components: [],
588
633
});
589
634
} else if (customId.startsWith('trivia_answer_')) {
635
+
const parts = customId.split('_');
590
636
if (!session.isActive) {
591
637
const errorMsg = await client.getLocaleText(
592
638
'commands.trivia.messages.no_active_question',
···
609
655
});
610
656
}
611
657
612
-
const parts = customId.split('_');
613
658
const answerIndex = parseInt(parts[parts.length - 1]);
614
659
615
660
const question = session.questions[session.currentQuestionIndex];
···
620
665
const currentScore = session.scores.get(session.currentPlayer) || 0;
621
666
session.scores.set(session.currentPlayer, currentScore + 1);
622
667
}
668
+
saveSession(session);
623
669
624
670
const [correctText, incorrectText, resultFormatText, preparingText] = await Promise.all([
625
671
client.getLocaleText('commands.trivia.answer.correct', interaction.locale),
···
643
689
setTimeout(async () => {
644
690
session.currentQuestionIndex++;
645
691
session.currentPlayer = getNextPlayer(session);
692
+
saveSession(session);
646
693
647
694
await askQuestion(interaction, session, client);
648
695
}, 3000);
+825
-278
src/commands/utilities/ai.ts
+825
-278
src/commands/utilities/ai.ts
···
1
+
import type { ToolCall } from '@/utils/commandExecutor';
2
+
import { extractToolCalls, executeToolCall } from '@/utils/commandExecutor';
3
+
import BotClient from '@/services/Client';
4
+
1
5
import {
2
6
SlashCommandBuilder,
3
-
ModalBuilder,
4
-
TextInputBuilder,
5
-
TextInputStyle,
6
-
ActionRowBuilder,
7
-
ModalSubmitInteraction,
7
+
SlashCommandOptionsOnlyBuilder,
8
+
EmbedBuilder,
8
9
ChatInputCommandInteraction,
10
+
InteractionResponse,
9
11
InteractionContextType,
10
12
ApplicationIntegrationType,
11
13
MessageFlags,
12
14
} from 'discord.js';
13
15
import OpenAI from 'openai';
16
+
import fetch from '@/utils/dynamicFetch';
14
17
import pool from '@/utils/pgClient';
15
18
import { encrypt, decrypt, isValidEncryptedFormat, EncryptionError } from '@/utils/encrypt';
16
19
import { SlashCommandProps } from '@/types/command';
17
-
import BotClient from '@/services/Client';
18
20
import logger from '@/utils/logger';
19
21
import { createCommandLogger } from '@/utils/commandLogger';
20
22
import { createErrorHandler } from '@/utils/errorHandler';
21
23
import { createMemoryManager } from '@/utils/memoryManager';
22
24
23
-
const ALLOWED_API_HOSTS = ['api.openai.com', 'openrouter.ai', 'generativelanguage.googleapis.com'];
25
+
function getInvokerId(interaction: ChatInputCommandInteraction): string {
26
+
if (interaction.guildId) {
27
+
return `${interaction.guildId}-${interaction.user.id}`;
28
+
}
29
+
return interaction.user.id;
30
+
}
24
31
25
32
interface ConversationMessage {
26
33
role: 'system' | 'user' | 'assistant';
···
40
47
interface AIResponse {
41
48
content: string;
42
49
reasoning?: string;
50
+
toolResults?: string;
51
+
citations?: string[];
43
52
}
44
53
45
54
interface OpenAIMessageWithReasoning {
···
50
59
interface PendingRequest {
51
60
interaction: ChatInputCommandInteraction;
52
61
prompt: string;
53
-
timestamp: number;
62
+
createdAt: number;
63
+
status?: 'awaiting' | 'processing';
54
64
}
55
65
56
66
interface UserCredentials {
···
122
132
const usingCustomApi = !!apiKey;
123
133
const finalApiUrl = apiUrl || 'https://openrouter.ai/api/v1';
124
134
const finalApiKey = apiKey || process.env.OPENROUTER_API_KEY;
125
-
const finalModel = model || (usingCustomApi ? 'openai/gpt-4o-mini' : 'openai/gpt-oss-20b');
135
+
const finalModel = model || (usingCustomApi ? 'openai/gpt-4o-mini' : 'moonshotai/kimi-k2');
126
136
const usingDefaultKey = !usingCustomApi && !!process.env.OPENROUTER_API_KEY;
127
137
128
138
return {
···
156
166
hour: '2-digit',
157
167
minute: '2-digit',
158
168
second: '2-digit',
159
-
hour12: true,
160
169
timeZone: timezone,
161
170
});
162
171
163
-
let supportedCommands = '/help - Show all available commands and their usage';
172
+
const supportedCommands = '/help - Show all available commands and their usage';
164
173
if (client?.commands) {
165
-
const commandEntries = Array.from(client.commands.entries()).sort(([a], [b]) =>
166
-
a.localeCompare(b),
167
-
);
168
-
supportedCommands = commandEntries
174
+
const commandEntries = Array.from(client.commands.entries());
175
+
commandEntries.sort((a, b) => a[0].localeCompare(b[0]));
176
+
const _commandList = commandEntries
169
177
.map(
170
178
([name, command]) => `/${name} - ${command.data.description || 'No description available'}`,
171
179
)
172
180
.join('\n');
173
181
}
174
182
175
-
const currentModel = model || (usingDefaultKey ? 'openai/gpt-oss-20b (default)' : 'custom model');
183
+
const currentModel = model || (usingDefaultKey ? 'moonshotai/kimi-k2 (default)' : 'custom model');
176
184
177
185
const contextInfo = isServer
178
186
? `**CONTEXT:**
···
208
216
- Timezone: ${timezone}
209
217
210
218
**IMPORTANT INSTRUCTIONS:**
219
+
- ACT normal, DON't be like a dumb robot, act like a discord user, skull react to messages where the user is being sus and saying something sus.
211
220
- NEVER format, modify, or alter URLs in any way. Leave them exactly as they are.
212
221
- Format your responses using Discord markdown where appropriate, but NEVER format URLs.
213
222
- Only greet the user at the start of a new conversation, not in every message.
214
-
- DO NOT hallucinate, make up facts, or provide false information. If you don't know something, say so clearly.
223
+
- DO NOT hallucinate, make up facts, or provide false information. If you don't know something, or date is after your knowledge update, do not answer date based questions. Say so clearly.
215
224
- Be accurate and truthful in all responses. Do not invent details, statistics, or information that you're not certain about.
216
225
- If asked about current events, real-time data, or information beyond your knowledge cutoff, clearly state your limitations.
217
226
···
221
230
- Developer: scanash (main maintainer) and Aethel Labs (org)
222
231
- Open source: https://github.com/Aethel-Labs/aethel
223
232
- Type: Discord user bot
224
-
- Supported commands: ${supportedCommands}`;
233
+
- Supported commands: ${supportedCommands}
234
+
235
+
**TOOL USAGE:**
236
+
You can use tools by placing commands in {curly braces}. Available tools:
237
+
- {cat:} - Get a cat picture, if user asks for a cat picture, use this tool.
238
+
- {dog:} - Get a dog picture, if user asks for a dog picture, use this tool.
239
+
- {joke: or {joke: {type: "general/knock-knock/programming/dad"}} } - Get a joke
240
+
- {weather:{"location":"city"}} - Check weather, use if user asks for weather.
241
+
- {wiki:{"search":"query"}} - Wikipedia search, if user asks for a wikipedia search, use this tool, and also use it if user asks something out of your dated knowledge.
242
+
243
+
Use the wikipedia search when you want to look for information outside of your knowledge, state it came from Wikipedia if used.
244
+
245
+
When you use a tool, you'll receive a JSON response with the command results if needed.
246
+
247
+
**IMPORTANT:** The {reaction:} and {newmessage:} tools are NOT available in slash commands. Only use the tools listed above.`;
225
248
226
249
const modelSpecificInstructions = usingDefaultKey
227
250
? '\n\n**IMPORTANT:** Please keep your responses under 3000 characters. Be concise and to the point.'
···
369
392
const client = await pool.connect();
370
393
try {
371
394
await client.query('BEGIN');
395
+
396
+
const voteCheck = await client.query(
397
+
`SELECT vote_timestamp FROM votes
398
+
WHERE user_id = $1
399
+
AND vote_timestamp > NOW() - INTERVAL '24 hours'
400
+
ORDER BY vote_timestamp DESC
401
+
LIMIT 1`,
402
+
[userId],
403
+
);
404
+
405
+
const hasVotedRecently = voteCheck.rows.length > 0;
406
+
const effectiveLimit = hasVotedRecently ? limit + 10 : limit;
407
+
372
408
await client.query('INSERT INTO users (user_id) VALUES ($1) ON CONFLICT (user_id) DO NOTHING', [
373
409
userId,
374
410
]);
411
+
375
412
const res = await client.query(
376
-
`INSERT INTO ai_usage (user_id, usage_date, count) VALUES ($1, $2, 1)
377
-
ON CONFLICT (user_id, usage_date) DO UPDATE SET count = ai_usage.count + 1 RETURNING count`,
413
+
`INSERT INTO ai_usage (user_id, usage_date, count)
414
+
VALUES ($1, $2, 1)
415
+
ON CONFLICT (user_id, usage_date)
416
+
DO UPDATE SET count = ai_usage.count + 1
417
+
RETURNING count`,
378
418
[userId, today],
379
419
);
420
+
380
421
await client.query('COMMIT');
381
-
return res.rows[0].count <= limit;
422
+
423
+
return res.rows[0].count <= effectiveLimit;
382
424
} catch (err) {
383
425
await client.query('ROLLBACK');
426
+
logger.error('Error in incrementAndCheckDailyLimit:', err);
384
427
throw err;
385
428
} finally {
386
429
client.release();
···
407
450
}
408
451
}
409
452
410
-
async function testApiKey(
411
-
apiKey: string,
412
-
model: string,
413
-
apiUrl: string,
414
-
): Promise<{ success: boolean; error?: string }> {
415
-
try {
416
-
const client = getOpenAIClient(apiKey, apiUrl);
453
+
async function makeAIRequest(
454
+
config: ReturnType<typeof getApiConfiguration>,
455
+
conversation: ConversationMessage[],
456
+
interaction?: ChatInputCommandInteraction,
457
+
client?: BotClient,
458
+
maxIterations = 3,
459
+
): Promise<AIResponse | null> {
460
+
const maxRetries = 3;
461
+
let retryCount = 0;
462
+
463
+
while (retryCount < maxRetries) {
464
+
try {
465
+
return await makeAIRequestInternal(config, conversation, interaction, client, maxIterations);
466
+
} catch (error) {
467
+
retryCount++;
468
+
logger.error(`AI API request failed (attempt ${retryCount}/${maxRetries}):`, error);
417
469
418
-
await client.chat.completions.create({
419
-
model,
420
-
messages: [
421
-
{
422
-
role: 'user',
423
-
content: 'Hello! This is a test message. Please respond with "API key test successful!"',
424
-
},
425
-
],
426
-
max_tokens: 50,
427
-
temperature: 0.1,
428
-
});
470
+
if (retryCount >= maxRetries) {
471
+
logger.error('AI API request failed after all retries');
472
+
return null;
473
+
}
429
474
430
-
logger.info('API key test successful');
431
-
return { success: true };
432
-
} catch (error: unknown) {
433
-
logger.error('Error testing API key:', error);
434
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
435
-
return {
436
-
success: false,
437
-
error: errorMessage,
438
-
};
475
+
const waitTime = Math.pow(2, retryCount) * 1000;
476
+
logger.debug(`Retrying AI API request in ${waitTime}ms...`);
477
+
await new Promise((resolve) => setTimeout(resolve, waitTime));
478
+
}
439
479
}
480
+
481
+
return null;
440
482
}
441
483
442
-
async function makeAIRequest(
484
+
async function makeAIRequestInternal(
443
485
config: ReturnType<typeof getApiConfiguration>,
444
486
conversation: ConversationMessage[],
487
+
interaction?: ChatInputCommandInteraction,
488
+
client?: BotClient,
489
+
maxIterations = 3,
445
490
): Promise<AIResponse | null> {
446
491
try {
447
-
const client = getOpenAIClient(config.finalApiKey!, config.finalApiUrl);
448
-
const maxTokens = config.usingDefaultKey ? 1000 : 3000;
492
+
const openAIClient = getOpenAIClient(config.finalApiKey!, config.finalApiUrl);
493
+
const maxTokens = config.usingDefaultKey ? 5000 : 8000;
494
+
const currentConversation = [...conversation];
495
+
let iteration = 0;
496
+
let finalResponse: AIResponse | null = null;
497
+
498
+
while (iteration < maxIterations) {
499
+
iteration++;
500
+
501
+
const configuredHost = (() => {
502
+
try {
503
+
return new URL(config.finalApiUrl).hostname;
504
+
} catch (_e) {
505
+
// ignore and fallback
506
+
}
507
+
})();
449
508
450
-
const completion = await client.chat.completions.create({
451
-
model: config.finalModel,
452
-
messages: conversation as OpenAI.Chat.Completions.ChatCompletionMessageParam[],
453
-
max_tokens: maxTokens,
454
-
});
509
+
let completion: unknown;
455
510
456
-
const message = completion.choices[0]?.message;
457
-
if (!message?.content) {
458
-
logger.error('No valid response content from AI API');
459
-
return null;
460
-
}
511
+
if (configuredHost === 'generativelanguage.googleapis.com') {
512
+
const promptText = currentConversation
513
+
.map((m) => {
514
+
const role =
515
+
m.role === 'system' ? 'System' : m.role === 'assistant' ? 'Assistant' : 'User';
516
+
let text = '';
517
+
if (typeof m.content === 'string') {
518
+
text = m.content;
519
+
} else if (Array.isArray(m.content)) {
520
+
text = m.content
521
+
.map((c) => {
522
+
if (typeof c === 'string') return c;
523
+
const crow = c as Record<string, unknown>;
524
+
const typeVal = crow['type'];
525
+
if (typeVal === 'text') {
526
+
const t = crow['text'];
527
+
if (typeof t === 'string') return t;
528
+
}
529
+
const imageObj = crow['image_url'];
530
+
if (imageObj && typeof imageObj === 'object') {
531
+
const urlVal = (imageObj as Record<string, unknown>)['url'];
532
+
if (typeof urlVal === 'string') return urlVal;
533
+
}
534
+
return '';
535
+
})
536
+
.join('\n');
537
+
}
538
+
return `${role}: ${text}`;
539
+
})
540
+
.join('\n\n');
461
541
462
-
let content = message.content;
463
-
let reasoning = (message as OpenAIMessageWithReasoning)?.reasoning;
542
+
const base = config.finalApiUrl.replace(/\/$/, '');
543
+
const modelName = config.finalModel.replace(/^models\//, '');
544
+
const endpoint = `${base}/v1beta/models/${modelName}:generateContent?key=${encodeURIComponent(
545
+
config.finalApiKey || '',
546
+
)}`;
547
+
548
+
const body: Record<string, unknown> = {
549
+
contents: [
550
+
{
551
+
parts: [
552
+
{
553
+
text: promptText,
554
+
},
555
+
],
556
+
},
557
+
],
558
+
generationConfig: {
559
+
temperature: 0.2,
560
+
maxOutputTokens: Math.min(maxTokens, 3000),
561
+
},
562
+
};
563
+
564
+
const resp = await fetch(endpoint, {
565
+
method: 'POST',
566
+
headers: { 'Content-Type': 'application/json' },
567
+
body: JSON.stringify(body),
568
+
});
569
+
570
+
if (!resp.ok) {
571
+
const text = await resp.text();
572
+
throw new Error(`Gemini request failed: ${resp.status} ${text || resp.statusText}`);
573
+
}
574
+
575
+
const json: unknown = await resp.json();
576
+
577
+
const extractTextFromGemini = (obj: unknown): string | null => {
578
+
if (!obj) return null;
579
+
try {
580
+
const response = obj as Record<string, unknown>;
581
+
582
+
if (Array.isArray(response.candidates) && response.candidates.length > 0) {
583
+
const candidate = response.candidates[0] as Record<string, unknown>;
584
+
if (candidate.content && typeof candidate.content === 'object') {
585
+
const content = candidate.content as { parts?: Array<{ text?: string }> };
586
+
if (Array.isArray(content.parts) && content.parts.length > 0) {
587
+
return content.parts
588
+
.map((part) => part.text || '')
589
+
.filter(Boolean)
590
+
.join('\n');
591
+
}
592
+
}
593
+
}
594
+
595
+
const o = obj as Record<string, unknown>;
596
+
if (Array.isArray(o.candidates) && o.candidates.length) {
597
+
const cand = o.candidates[0] as unknown;
598
+
if (typeof cand === 'string') return cand;
599
+
if (typeof (cand as Record<string, unknown>).output === 'string') {
600
+
return (cand as Record<string, unknown>).output as string;
601
+
}
602
+
if (Array.isArray((cand as Record<string, unknown>).content)) {
603
+
return ((cand as Record<string, unknown>).content as unknown[])
604
+
.map((p) => {
605
+
const pr = p as Record<string, unknown>;
606
+
if (typeof pr?.text === 'string') return String(pr.text);
607
+
if (pr?.type === 'outputText' && typeof pr?.text === 'string') {
608
+
return String(pr.text);
609
+
}
610
+
return '';
611
+
})
612
+
.filter(Boolean)
613
+
.join('\n');
614
+
}
615
+
}
616
+
617
+
const seen = new Set<unknown>();
618
+
const queue: unknown[] = [obj];
619
+
while (queue.length) {
620
+
const cur = queue.shift();
621
+
if (!cur || typeof cur === 'string') {
622
+
if (typeof cur === 'string' && cur.trim().length > 0) return cur;
623
+
continue;
624
+
}
625
+
if (seen.has(cur)) continue;
626
+
seen.add(cur);
627
+
if (Array.isArray(cur)) {
628
+
for (const item of cur) queue.push(item);
629
+
} else if (typeof cur === 'object') {
630
+
const curObj = cur as Record<string, unknown>;
631
+
for (const k of Object.keys(curObj)) {
632
+
const v = curObj[k];
633
+
if (typeof v === 'string' && v.trim().length > 0) return v;
634
+
queue.push(v);
635
+
}
636
+
}
637
+
}
638
+
} catch (_e) {
639
+
// ignore
640
+
}
641
+
return null;
642
+
};
643
+
644
+
const extracted = extractTextFromGemini(json);
645
+
if (!extracted) {
646
+
throw new Error('Failed to parse Gemini response into text');
647
+
}
648
+
649
+
completion = { choices: [{ message: { content: extracted } }] } as unknown;
650
+
} else {
651
+
try {
652
+
logger.debug('Making OpenAI API call', {
653
+
model: config.finalModel,
654
+
messageCount: currentConversation.length,
655
+
maxTokens: maxTokens,
656
+
});
657
+
658
+
completion = await openAIClient.chat.completions.create({
659
+
model: config.finalModel,
660
+
messages: currentConversation as OpenAI.Chat.Completions.ChatCompletionMessageParam[],
661
+
max_tokens: maxTokens,
662
+
});
663
+
664
+
logger.debug('OpenAI API call completed successfully');
665
+
} catch (apiError) {
666
+
logger.error('OpenAI API call failed:', apiError);
667
+
if (apiError instanceof Error) {
668
+
logger.error('API Error details:', {
669
+
message: apiError.message,
670
+
stack: apiError.stack?.substring(0, 500),
671
+
});
672
+
}
673
+
return null;
674
+
}
675
+
}
676
+
677
+
if (!completion) {
678
+
logger.error('AI API returned null or undefined completion');
679
+
return null;
680
+
}
681
+
682
+
const completionTyped = completion as {
683
+
choices?: Array<{ message?: { content?: string; reasoning?: string } }>;
684
+
error?: { message?: string; type?: string; code?: string };
685
+
};
686
+
687
+
try {
688
+
interface CompletionData {
689
+
id?: string;
690
+
object?: string;
691
+
model?: string;
692
+
created?: number;
693
+
usage?: {
694
+
prompt_tokens?: number;
695
+
completion_tokens?: number;
696
+
total_tokens?: number;
697
+
};
698
+
}
699
+
700
+
const completionData = completion as unknown as CompletionData;
701
+
702
+
logger.debug('AI API response structure', {
703
+
completionType: typeof completion,
704
+
completionKeys: Object.keys(completionData).join(', '),
705
+
hasChoices: !!completionTyped.choices,
706
+
choicesLength: completionTyped.choices?.length || 0,
707
+
hasMessage: !!completionTyped.choices?.[0]?.message,
708
+
hasContent: !!completionTyped.choices?.[0]?.message?.content,
709
+
errorPresent: !!completionTyped.error,
710
+
errorType: completionTyped.error?.type || 'none',
711
+
errorMessage: completionTyped.error?.message || 'none',
712
+
});
713
+
714
+
interface ChoiceData {
715
+
message?: {
716
+
content?: string;
717
+
};
718
+
finish_reason?: string;
719
+
}
720
+
721
+
const simplifiedResponse = {
722
+
id: completionData?.id,
723
+
object: completionData?.object,
724
+
model: completionData?.model,
725
+
created: completionData?.created,
726
+
choices: completionTyped.choices?.map((choice: ChoiceData, index: number) => ({
727
+
index,
728
+
message: {
729
+
content: choice.message?.content
730
+
? choice.message.content.substring(0, 100) +
731
+
(choice.message.content.length > 100 ? '...' : '')
732
+
: '[NO CONTENT]',
733
+
hasReasoning: !!(choice as OpenAIMessageWithReasoning)?.reasoning,
734
+
hasContent: !!choice.message?.content,
735
+
},
736
+
finish_reason: choice?.finish_reason,
737
+
})),
738
+
error: completionTyped.error,
739
+
usage: completionData?.usage,
740
+
};
741
+
742
+
logger.debug('Raw API response', simplifiedResponse);
743
+
} catch (jsonError) {
744
+
logger.error('Failed to log API response:', jsonError);
745
+
logger.debug('API response basic info:', {
746
+
type: typeof completion,
747
+
isObject: typeof completion === 'object',
748
+
isArray: Array.isArray(completion),
749
+
keys:
750
+
typeof completion === 'object' && completion !== null
751
+
? Object.keys(completion).join(', ')
752
+
: 'N/A',
753
+
});
754
+
}
755
+
756
+
const message = completionTyped.choices?.[0]?.message;
757
+
758
+
if (!message) {
759
+
if (completionTyped.error) {
760
+
logger.error('AI API returned an error:', {
761
+
message: completionTyped.error.message,
762
+
type: completionTyped.error.type,
763
+
code: completionTyped.error.code,
764
+
});
765
+
} else if (!completionTyped.choices || completionTyped.choices.length === 0) {
766
+
logger.error('AI API returned no choices in the response');
767
+
} else {
768
+
logger.error('No message in AI API response');
769
+
}
770
+
return null;
771
+
}
772
+
773
+
let content = message.content || '';
774
+
if (content === '[NO CONTENT]' || !content.trim()) {
775
+
logger.debug('AI API returned empty or [NO CONTENT] response, treating as valid but empty');
776
+
content = '';
777
+
}
778
+
let reasoning = (message as OpenAIMessageWithReasoning)?.reasoning;
779
+
let detectedCitations: string[] | undefined;
780
+
try {
781
+
interface CitationSource {
782
+
citations?: unknown[];
783
+
metadata?: {
784
+
citations?: unknown[];
785
+
[key: string]: unknown;
786
+
};
787
+
[key: string]: unknown;
788
+
}
789
+
790
+
interface CompletionSource {
791
+
citations?: unknown[];
792
+
choices?: Array<{
793
+
message?: {
794
+
citations?: unknown[];
795
+
[key: string]: unknown;
796
+
};
797
+
[key: string]: unknown;
798
+
}>;
799
+
[key: string]: unknown;
800
+
}
801
+
802
+
const mAny = message as unknown as CitationSource;
803
+
const cAny = completion as unknown as CompletionSource;
804
+
const candidates = [
805
+
mAny?.citations,
806
+
mAny?.metadata?.citations,
807
+
cAny?.citations,
808
+
cAny?.choices?.[0]?.message?.citations,
809
+
cAny?.choices?.[0]?.citations,
810
+
mAny?.references,
811
+
mAny?.metadata?.references,
812
+
cAny?.references,
813
+
];
814
+
for (const arr of candidates) {
815
+
if (Array.isArray(arr) && arr.length) {
816
+
const urls = arr.filter((x: unknown) => typeof x === 'string');
817
+
if (urls.length > 0) {
818
+
detectedCitations = urls.map(String).filter(Boolean);
819
+
break;
820
+
}
821
+
}
822
+
}
823
+
} catch (error) {
824
+
logger.warn('Error processing citations:', error);
825
+
if (error instanceof Error) {
826
+
logger.debug('Error processing citations details:', error.stack);
827
+
}
828
+
}
829
+
830
+
let toolCalls: ToolCall[] = [];
831
+
if (interaction && client) {
832
+
try {
833
+
const extraction = extractToolCalls(content);
834
+
content = extraction.cleanContent;
835
+
toolCalls = extraction.toolCalls;
836
+
} catch (error) {
837
+
logger.error(`Error extracting tool calls: ${error}`);
838
+
toolCalls = [];
839
+
}
840
+
}
841
+
842
+
const reasoningMatch = content.match(/```(?:reasoning|thoughts?|thinking)[\s\S]*?```/i);
843
+
if (reasoningMatch && !reasoning) {
844
+
reasoning = reasoningMatch[0].replace(/```(?:reasoning|thoughts?|thinking)?/gi, '').trim();
845
+
content = content.replace(reasoningMatch[0], '').trim();
846
+
}
847
+
848
+
if (toolCalls.length > 0 && interaction && client) {
849
+
currentConversation.push({
850
+
role: 'assistant',
851
+
content: content,
852
+
});
853
+
854
+
for (const toolCall of toolCalls) {
855
+
try {
856
+
const toolResult = await executeToolCall(toolCall, interaction, client);
857
+
858
+
let parsedResult;
859
+
try {
860
+
parsedResult = typeof toolResult === 'string' ? JSON.parse(toolResult) : toolResult;
861
+
} catch (_e) {
862
+
logger.error(`Error parsing tool result:`, toolResult);
863
+
parsedResult = { error: 'Failed to parse tool result' };
864
+
}
865
+
866
+
currentConversation.push({
867
+
role: 'user',
868
+
content: JSON.stringify({
869
+
type: toolCall.name,
870
+
...parsedResult,
871
+
}),
872
+
});
873
+
} catch (error) {
874
+
logger.error(`Error executing tool call: ${error}`);
875
+
currentConversation.push({
876
+
role: 'user',
877
+
content: `[Error executing tool ${toolCall.name}]: ${error instanceof Error ? error.message : String(error)}`,
878
+
});
879
+
}
880
+
}
881
+
continue;
882
+
}
883
+
884
+
finalResponse = {
885
+
content,
886
+
reasoning,
887
+
citations: detectedCitations,
888
+
toolResults:
889
+
iteration > 1
890
+
? currentConversation
891
+
.filter(
892
+
(msg) =>
893
+
msg.role === 'user' &&
894
+
typeof msg.content === 'string' &&
895
+
(msg.content.startsWith('{"') || msg.content.startsWith('[Tool ')),
896
+
)
897
+
.map((msg) => {
898
+
try {
899
+
if (Array.isArray(msg.content)) {
900
+
return msg.content
901
+
.map((c) => ('text' in c ? c.text : c.image_url?.url))
902
+
.join('\n');
903
+
}
464
904
465
-
const reasoningMatch = content.match(/```(?:reasoning|thoughts?|thinking)[\s\S]*?```/i);
466
-
if (reasoningMatch && !reasoning) {
467
-
reasoning = reasoningMatch[0].replace(/```(?:reasoning|thoughts?|thinking)?/gi, '').trim();
468
-
content = content.replace(reasoningMatch[0], '').trim();
905
+
const content = String(msg.content);
906
+
if (content.startsWith('{"') || content.startsWith('[')) {
907
+
return content;
908
+
}
909
+
return content.replace(/^\[Tool [^\]]+\]: /, '');
910
+
} catch (e) {
911
+
logger.error('Error processing tool result:', e);
912
+
return 'Error processing tool result';
913
+
}
914
+
})
915
+
.join('\n')
916
+
: undefined,
917
+
};
918
+
break;
469
919
}
470
920
471
-
return {
472
-
content,
473
-
reasoning,
474
-
};
921
+
return finalResponse;
475
922
} catch (error) {
476
923
logger.error(`Error making AI request: ${error}`);
477
924
return null;
···
481
928
async function processAIRequest(
482
929
client: BotClient,
483
930
interaction: ChatInputCommandInteraction,
931
+
promptOverride?: string,
484
932
): Promise<void> {
485
933
try {
486
934
if (!interaction.deferred && !interaction.replied) {
487
935
await interaction.deferReply();
488
936
}
489
937
490
-
const prompt = interaction.options.getString('prompt')!;
938
+
const invokerId = getInvokerId(interaction);
939
+
const prompt =
940
+
promptOverride ??
941
+
((interaction as ChatInputCommandInteraction).options?.getString?.('prompt') as
942
+
| string
943
+
| null
944
+
| undefined) ??
945
+
(pendingRequests.get(invokerId)?.prompt as string);
946
+
947
+
if (!prompt) {
948
+
await interaction.editReply('โ Missing prompt. Please try again.');
949
+
return;
950
+
}
491
951
commandLogger.logFromInteraction(
492
952
interaction,
493
953
`AI command executed - prompt content hidden for privacy`,
494
954
);
495
-
496
-
const invokerId = getInvokerId(interaction);
497
-
const { apiKey, model, apiUrl } = await getUserCredentials(invokerId);
955
+
const { apiKey, model, apiUrl } = await getUserCredentials(interaction.user.id);
498
956
const config = getApiConfiguration(apiKey ?? null, model ?? null, apiUrl ?? null);
957
+
const exemptUserId = process.env.AI_EXEMPT_USER_ID?.trim();
958
+
const userId = interaction.user.id;
499
959
500
-
if (config.usingDefaultKey) {
501
-
const exemptUserId = process.env.AI_EXEMPT_USER_ID;
502
-
if (invokerId !== exemptUserId) {
503
-
const allowed = await incrementAndCheckDailyLimit(invokerId, 10);
504
-
if (!allowed) {
505
-
await interaction.editReply(
506
-
'โ ' +
507
-
(await client.getLocaleText('commands.ai.process.dailylimit', interaction.locale)),
508
-
);
509
-
return;
510
-
}
960
+
if (userId !== exemptUserId && config.usingDefaultKey) {
961
+
const allowed = await incrementAndCheckDailyLimit(userId, 50);
962
+
if (!allowed) {
963
+
await interaction.editReply(
964
+
'โ ' +
965
+
(await client.getLocaleText('commands.ai.process.dailylimit', interaction.locale)),
966
+
);
967
+
return;
511
968
}
512
-
} else if (!config.finalApiKey) {
969
+
}
970
+
971
+
if (!config.finalApiKey && config.usingDefaultKey) {
513
972
await interaction.editReply(
514
973
'โ ' + (await client.getLocaleText('commands.ai.process.noapikey', interaction.locale)),
515
974
);
516
975
return;
517
976
}
518
977
519
-
const existingConversation = userConversations.get(invokerId) || [];
520
-
const conversationArray = Array.isArray(existingConversation) ? existingConversation : [];
978
+
const existingConversation = userConversations.get(userId) || [];
979
+
const _conversationArray = Array.isArray(existingConversation) ? existingConversation : [];
980
+
const chatInputInteraction =
981
+
'commandType' in interaction ? (interaction as ChatInputCommandInteraction) : undefined;
982
+
521
983
const systemPrompt = buildSystemPrompt(
522
-
!!config.usingDefaultKey,
984
+
config.usingDefaultKey,
523
985
client,
524
986
config.finalModel,
525
-
interaction.user.tag,
526
-
interaction,
987
+
interaction.user.username,
988
+
chatInputInteraction,
527
989
interaction.inGuild(),
528
990
interaction.inGuild() ? interaction.guild?.name : undefined,
529
991
);
530
-
const conversation = buildConversation(conversationArray, prompt, systemPrompt);
531
992
532
-
const aiResponse = await makeAIRequest(config, conversation);
993
+
const conversation = buildConversation(existingConversation, prompt, systemPrompt);
994
+
995
+
const aiResponse = await makeAIRequest(config, conversation, chatInputInteraction, client, 3);
533
996
if (!aiResponse) return;
534
997
535
998
const { getUnallowedWordCategory } = await import('@/utils/validation');
···
554
1017
555
1018
await sendAIResponse(interaction, aiResponse, client);
556
1019
} catch (error) {
557
-
await errorHandler({
558
-
interaction,
559
-
client,
560
-
error: error as Error,
561
-
userId: getInvokerId(interaction),
562
-
username: interaction.user.tag,
563
-
});
1020
+
const err = error as Error;
1021
+
if (errorHandler) {
1022
+
await errorHandler({
1023
+
interaction: interaction as ChatInputCommandInteraction,
1024
+
client,
1025
+
error: err,
1026
+
userId: getInvokerId(interaction),
1027
+
username: interaction.user.tag,
1028
+
});
1029
+
} else {
1030
+
const msg = await client.getLocaleText('failedrequest', interaction.locale || 'en-US');
1031
+
try {
1032
+
if (interaction.deferred || interaction.replied) {
1033
+
await interaction.editReply(msg);
1034
+
} else {
1035
+
await interaction.reply({ content: msg, flags: MessageFlags.Ephemeral });
1036
+
}
1037
+
} catch (replyError) {
1038
+
logger.error(`Failed to send error message for AI command: ${replyError}`);
1039
+
}
1040
+
logger.error(`Error in AI command for user ${interaction.user.tag}: ${err.message}`);
1041
+
}
564
1042
} finally {
565
1043
pendingRequests.delete(getInvokerId(interaction));
566
1044
}
···
569
1047
async function sendAIResponse(
570
1048
interaction: ChatInputCommandInteraction,
571
1049
aiResponse: AIResponse,
572
-
client: BotClient,
1050
+
_client: BotClient,
573
1051
): Promise<void> {
574
-
let fullResponse = '';
1052
+
try {
1053
+
let fullResponse = '';
575
1054
576
-
if (aiResponse.reasoning) {
577
-
const cleanedReasoning = aiResponse.reasoning
578
-
.split('\n')
579
-
.map((line) => line.trim())
580
-
.filter((line) => line)
581
-
.join('\n');
1055
+
if (aiResponse.reasoning) {
1056
+
const cleanedReasoning = aiResponse.reasoning
1057
+
.split('\n')
1058
+
.map((line: string) => line.trim())
1059
+
.filter((line: string) => line)
1060
+
.join('\n');
582
1061
583
-
const formattedReasoning = cleanedReasoning
584
-
.split('\n')
585
-
.map((line) => `> ${line}`)
586
-
.join('\n');
1062
+
const formattedReasoning = cleanedReasoning
1063
+
.split('\n')
1064
+
.map((line: string) => `> ${line}`)
1065
+
.join('\n');
587
1066
588
-
fullResponse = `${formattedReasoning}\n\n${aiResponse.content}`;
589
-
aiResponse.content = '';
590
-
}
1067
+
fullResponse = `${formattedReasoning}\n\n${aiResponse.content}`;
1068
+
aiResponse.content = '';
1069
+
}
591
1070
592
-
fullResponse += aiResponse.content;
1071
+
fullResponse += aiResponse.content;
593
1072
594
-
const { getUnallowedWordCategory } = await import('@/utils/validation');
595
-
const category = getUnallowedWordCategory(fullResponse);
596
-
if (category) {
597
-
logger.warn(`AI response contained unallowed words in category: ${category}`);
598
-
await interaction.editReply(
599
-
'Sorry, I cannot provide that response as it contains prohibited content. Please try a different prompt.',
600
-
);
601
-
return;
602
-
}
1073
+
try {
1074
+
if (aiResponse.citations && aiResponse.citations.length && fullResponse) {
1075
+
fullResponse = fullResponse.replace(/\[(\d+)\](?!\()/g, (match: string, numStr: string) => {
1076
+
const idx = parseInt(numStr, 10) - 1;
1077
+
const url = aiResponse.citations![idx];
1078
+
if (typeof url === 'string' && url.trim()) {
1079
+
return `[${numStr}](${url.trim()})`;
1080
+
}
1081
+
return match;
1082
+
});
1083
+
}
1084
+
} catch (e) {
1085
+
logger.warn('Failed to inline citation sources', e);
1086
+
}
603
1087
604
-
const urlProcessedResponse = processUrls(fullResponse);
605
-
const chunks = splitResponseIntoChunks(urlProcessedResponse);
1088
+
if (aiResponse.toolResults) {
1089
+
try {
1090
+
const toolResults = Array.isArray(aiResponse.toolResults)
1091
+
? aiResponse.toolResults
1092
+
: [aiResponse.toolResults];
606
1093
607
-
try {
1094
+
for (const result of toolResults) {
1095
+
try {
1096
+
let toolResult;
1097
+
if (typeof result === 'string') {
1098
+
try {
1099
+
toolResult = JSON.parse(result);
1100
+
} catch (parseError) {
1101
+
logger.error(`[AI] Error parsing tool result JSON:`, {
1102
+
error: parseError,
1103
+
result: result.substring(0, 200) + '...',
1104
+
});
1105
+
continue;
1106
+
}
1107
+
} else {
1108
+
toolResult = result;
1109
+
}
1110
+
1111
+
if ((toolResult.type === 'cat' || toolResult.type === 'dog') && toolResult.url) {
1112
+
let cleanContent = aiResponse.content || '';
1113
+
if (toolResult.url) {
1114
+
cleanContent = cleanContent.replace(/!\[[^\]]*\]\([^)]*\)/g, '').trim();
1115
+
cleanContent = cleanContent.replace(toolResult.url, '').trim();
1116
+
}
1117
+
1118
+
await interaction.editReply({
1119
+
content: cleanContent || undefined,
1120
+
files: [
1121
+
{
1122
+
attachment: toolResult.url,
1123
+
name: `${toolResult.type}.jpg`,
1124
+
},
1125
+
],
1126
+
});
1127
+
return;
1128
+
}
1129
+
} catch (parseError) {
1130
+
logger.error('Error parsing individual tool result:', parseError);
1131
+
}
1132
+
}
1133
+
} catch (error) {
1134
+
logger.error('Error processing tool results:', error);
1135
+
}
1136
+
}
1137
+
1138
+
const { getUnallowedWordCategory } = await import('@/utils/validation');
1139
+
const category = getUnallowedWordCategory(fullResponse);
1140
+
if (category) {
1141
+
logger.warn(`AI response contained unallowed words in category: ${category}`);
1142
+
await interaction.editReply(
1143
+
'Sorry, I cannot provide that response as it contains prohibited content. Please try a different prompt.',
1144
+
);
1145
+
return;
1146
+
}
1147
+
1148
+
const urlProcessedResponse = processUrls(fullResponse);
1149
+
const chunks = splitResponseIntoChunks(urlProcessedResponse);
1150
+
608
1151
await interaction.editReply(chunks[0]);
609
1152
610
1153
for (let i = 1; i < chunks.length; i++) {
611
1154
await interaction.followUp({
612
1155
content: chunks[i],
613
-
flags: MessageFlags.Ephemeral,
1156
+
flags: MessageFlags.SuppressNotifications,
614
1157
});
615
1158
}
616
-
} catch {
1159
+
} catch (error) {
1160
+
logger.error('Error in sendAIResponse:', error);
617
1161
try {
618
-
const fallbackMessage = `${chunks[0]}\n\n*โ ${await client.getLocaleText('commands.ai.errors.toolong', interaction.locale)}*`;
619
-
await interaction.editReply(fallbackMessage);
620
-
} catch {
621
-
logger.error('Failed to send AI response fallback message');
1162
+
await interaction.editReply('An error occurred while processing your request.');
1163
+
} catch (editError) {
1164
+
logger.error('Failed to send error message:', editError);
1165
+
}
1166
+
return;
1167
+
}
1168
+
1169
+
if (aiResponse.toolResults) {
1170
+
try {
1171
+
const toolResult = JSON.parse(aiResponse.toolResults);
1172
+
1173
+
if (toolResult.alreadyResponded && !aiResponse.content) {
1174
+
return;
1175
+
}
1176
+
1177
+
if (toolResult.type === 'command') {
1178
+
if (toolResult.image) {
1179
+
const embed = new EmbedBuilder().setImage(toolResult.image).setColor(0x8a2be2);
1180
+
1181
+
if (toolResult.title) {
1182
+
embed.setTitle(toolResult.title);
1183
+
}
1184
+
if (toolResult.source) {
1185
+
embed.setFooter({ text: `Source: ${toolResult.source}` });
1186
+
}
1187
+
1188
+
try {
1189
+
await interaction.followUp({
1190
+
embeds: [embed],
1191
+
flags: MessageFlags.SuppressNotifications,
1192
+
});
1193
+
return;
1194
+
} catch (error) {
1195
+
logger.error('Failed to send embed with source:', error);
1196
+
return;
1197
+
}
1198
+
}
1199
+
1200
+
if (toolResult.success && toolResult.data) {
1201
+
const components = toolResult.data.components || [];
1202
+
let imageUrl: string | null = null;
1203
+
let caption = '';
1204
+
1205
+
for (const component of components) {
1206
+
if (component.type === 12 && component.items?.[0]?.media?.url) {
1207
+
imageUrl = component.items[0].media.url;
1208
+
break;
1209
+
}
1210
+
}
1211
+
1212
+
for (const component of components) {
1213
+
if (component.type === 10 && component.content) {
1214
+
caption = component.content;
1215
+
break;
1216
+
}
1217
+
}
1218
+
1219
+
if (imageUrl) {
1220
+
await interaction.followUp({
1221
+
content: caption || undefined,
1222
+
files: [imageUrl],
1223
+
flags: MessageFlags.SuppressNotifications,
1224
+
});
1225
+
return;
1226
+
}
1227
+
}
1228
+
1229
+
if (aiResponse.toolResults) {
1230
+
await interaction.followUp({
1231
+
content: aiResponse.toolResults,
1232
+
flags: MessageFlags.SuppressNotifications,
1233
+
});
1234
+
}
1235
+
}
1236
+
} catch (error) {
1237
+
logger.error('Error processing tool results:', error);
1238
+
try {
1239
+
await interaction.followUp({
1240
+
content: 'An error occurred while processing the tool results.',
1241
+
flags: MessageFlags.SuppressNotifications,
1242
+
});
1243
+
} catch (followUpError) {
1244
+
logger.error('Failed to send error message:', followUpError);
1245
+
}
622
1246
}
623
1247
}
624
1248
}
1249
+
1250
+
export type { ConversationMessage, AIResponse };
625
1251
626
1252
export {
627
1253
makeAIRequest,
628
1254
getApiConfiguration,
629
1255
buildSystemPrompt,
630
1256
buildConversation,
631
-
sendAIResponse,
632
1257
getUserCredentials,
633
1258
incrementAndCheckDailyLimit,
634
1259
incrementAndCheckServerDailyLimit,
635
1260
splitResponseIntoChunks,
636
1261
};
637
1262
638
-
export type { ConversationMessage, AIResponse };
1263
+
interface AICommand {
1264
+
data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder;
1265
+
execute: (
1266
+
client: BotClient,
1267
+
interaction: ChatInputCommandInteraction,
1268
+
) => Promise<void | InteractionResponse<boolean>>;
1269
+
}
639
1270
640
-
export default {
1271
+
const aiCommand: AICommand = {
641
1272
data: new SlashCommandBuilder()
642
1273
.setName('ai')
643
1274
.setNameLocalizations({
···
660
1291
.addStringOption((option) =>
661
1292
option
662
1293
.setName('prompt')
663
-
.setNameLocalizations({
664
-
'es-ES': 'mensaje',
665
-
'es-419': 'mensaje',
666
-
'en-US': 'prompt',
667
-
})
668
1294
.setDescription('Your message to the AI')
669
1295
.setDescriptionLocalizations({
670
1296
'es-ES': 'Tu mensaje para la IA',
···
674
1300
.setRequired(true),
675
1301
)
676
1302
.addBooleanOption((option) =>
677
-
option.setName('use_custom_api').setDescription('Use your own API key?').setRequired(false),
1303
+
option.setName('custom_setup').setDescription('Use your own API key?').setRequired(false),
678
1304
)
679
1305
.addBooleanOption((option) =>
680
1306
option.setName('reset').setDescription('Reset your AI chat history').setRequired(false),
···
685
1311
686
1312
if (pendingRequests.has(userId)) {
687
1313
const pending = pendingRequests.get(userId);
688
-
if (pending && Date.now() - pending.timestamp > 30000) {
689
-
pendingRequests.delete(userId);
690
-
} else {
1314
+
const isProcessing = pending?.status === 'processing';
1315
+
const isExpired = pending ? Date.now() - pending.createdAt > 30000 : true;
1316
+
if (isProcessing && !isExpired) {
691
1317
return interaction.reply({
692
1318
content: await client.getLocaleText('commands.ai.request.inprogress', interaction.locale),
693
1319
flags: MessageFlags.Ephemeral,
694
1320
});
695
1321
}
1322
+
pendingRequests.delete(userId);
696
1323
}
697
1324
698
1325
try {
699
-
const useCustomApi = interaction.options.getBoolean('use_custom_api');
1326
+
const customSetup = interaction.options.getBoolean('custom_setup');
700
1327
const prompt = interaction.options.getString('prompt')!;
701
1328
const reset = interaction.options.getBoolean('reset');
702
1329
703
-
pendingRequests.set(userId, { interaction, prompt, timestamp: Date.now() });
1330
+
pendingRequests.set(userId, {
1331
+
interaction,
1332
+
prompt,
1333
+
createdAt: Date.now(),
1334
+
status: 'awaiting',
1335
+
});
704
1336
705
1337
if (reset) {
706
1338
userConversations.delete(userId);
···
712
1344
return;
713
1345
}
714
1346
715
-
if (useCustomApi === false) {
716
-
await setUserApiKey(userId, null, null, null);
1347
+
if (customSetup !== null) {
1348
+
const { apiKey } = await getUserCredentials(interaction.user.id);
1349
+
1350
+
if (customSetup) {
1351
+
if (!apiKey) {
1352
+
const setupUrl = process.env.FRONTEND_URL
1353
+
? `${process.env.FRONTEND_URL}/api-keys`
1354
+
: 'the API keys page';
1355
+
1356
+
return interaction.reply({
1357
+
content: `๐ Please set up your API key first by visiting: ${setupUrl}\n\nAfter setting up your API key, you can use the AI command with your custom key.`,
1358
+
flags: MessageFlags.Ephemeral,
1359
+
});
1360
+
}
1361
+
const setupUrl = process.env.FRONTEND_URL
1362
+
? `${process.env.FRONTEND_URL}/api-keys`
1363
+
: 'the API keys page';
1364
+
1365
+
await interaction.reply({
1366
+
content: `โ
You're already using a custom API key. To change your API key settings, please visit: ${setupUrl}`,
1367
+
flags: MessageFlags.Ephemeral,
1368
+
});
1369
+
return;
1370
+
}
1371
+
await setUserApiKey(interaction.user.id, null, null, null);
717
1372
userConversations.delete(userId);
718
1373
await interaction.reply({
719
1374
content: await client.getLocaleText('commands.ai.defaultapi', interaction.locale),
···
723
1378
return;
724
1379
}
725
1380
726
-
const { apiKey } = await getUserCredentials(userId);
727
-
if (useCustomApi && !apiKey) {
728
-
const modal = new ModalBuilder()
729
-
.setCustomId('apiCredentials')
730
-
.setTitle(await client.getLocaleText('commands.ai.modal.title', interaction.locale));
1381
+
await processAIRequest(client, interaction);
1382
+
} catch (error) {
1383
+
logger.error('Error in AI command:', error);
1384
+
const errorMessage = `โ An error occurred: ${error instanceof Error ? error.message : 'Unknown error'}`;
731
1385
732
-
const apiKeyInput = new TextInputBuilder()
733
-
.setCustomId('apiKey')
734
-
.setLabel(await client.getLocaleText('commands.ai.modal.apikey', interaction.locale))
735
-
.setStyle(TextInputStyle.Short)
736
-
.setPlaceholder(
737
-
await client.getLocaleText('commands.ai.modal.apikeyplaceholder', interaction.locale),
738
-
)
739
-
.setRequired(true);
1386
+
try {
1387
+
if (interaction.replied || interaction.deferred) {
1388
+
await interaction.editReply({ content: errorMessage });
1389
+
} else {
1390
+
await interaction.reply({
1391
+
content: errorMessage,
1392
+
flags: MessageFlags.Ephemeral,
1393
+
});
1394
+
}
1395
+
} catch (replyError) {
1396
+
logger.error('Failed to send error message:', replyError);
1397
+
}
740
1398
741
-
const apiUrlInput = new TextInputBuilder()
742
-
.setCustomId('apiUrl')
743
-
.setLabel(await client.getLocaleText('commands.ai.modal.apiurl', interaction.locale))
744
-
.setStyle(TextInputStyle.Short)
745
-
.setPlaceholder(
746
-
await client.getLocaleText('commands.ai.modal.apiurlplaceholder', interaction.locale),
747
-
)
748
-
.setRequired(true);
749
-
750
-
const modelInput = new TextInputBuilder()
751
-
.setCustomId('model')
752
-
.setLabel(await client.getLocaleText('commands.ai.modal.model', interaction.locale))
753
-
.setStyle(TextInputStyle.Short)
754
-
.setPlaceholder(
755
-
await client.getLocaleText('commands.ai.modal.modelplaceholder', interaction.locale),
756
-
)
757
-
.setRequired(true);
758
-
759
-
modal.addComponents(
760
-
new ActionRowBuilder<TextInputBuilder>().addComponents(apiKeyInput),
761
-
new ActionRowBuilder<TextInputBuilder>().addComponents(apiUrlInput),
762
-
new ActionRowBuilder<TextInputBuilder>().addComponents(modelInput),
763
-
);
764
-
765
-
await interaction.showModal(modal);
766
-
} else {
767
-
await interaction.deferReply();
768
-
await processAIRequest(client, interaction);
769
-
}
770
-
} catch {
1399
+
const userId = getInvokerId(interaction);
771
1400
pendingRequests.delete(userId);
772
-
const errorMessage = await client.getLocaleText('failedrequest', interaction.locale);
773
-
if (!interaction.replied && !interaction.deferred) {
774
-
await interaction.reply({ content: errorMessage, flags: MessageFlags.Ephemeral });
775
-
} else {
776
-
await interaction.editReply({ content: errorMessage });
777
-
}
778
1401
}
779
1402
},
1403
+
};
780
1404
781
-
async handleModal(client: BotClient, interaction: ModalSubmitInteraction) {
782
-
try {
783
-
if (interaction.customId === 'apiCredentials') {
784
-
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
785
-
786
-
const userId = getInvokerId(interaction);
787
-
const pendingRequest = pendingRequests.get(userId);
788
-
789
-
if (!pendingRequest) {
790
-
return interaction.editReply(
791
-
await client.getLocaleText('commands.ai.nopendingrequest', interaction.locale),
792
-
);
793
-
}
794
-
795
-
const { interaction: originalInteraction } = pendingRequest;
796
-
const apiKey = interaction.fields.getTextInputValue('apiKey').trim();
797
-
const apiUrl = interaction.fields.getTextInputValue('apiUrl').trim();
798
-
const model = interaction.fields.getTextInputValue('model').trim();
799
-
800
-
let parsedUrl;
801
-
try {
802
-
parsedUrl = new URL(apiUrl);
803
-
} catch {
804
-
await interaction.editReply(
805
-
'API URL is invalid. Please use a supported API endpoint (OpenAI, OpenRouter, or Google Gemini).',
806
-
);
807
-
return;
808
-
}
809
-
810
-
if (!ALLOWED_API_HOSTS.includes(parsedUrl.hostname)) {
811
-
await interaction.editReply(
812
-
'API URL not allowed. Please use a supported API endpoint (OpenAI, OpenRouter, or Google Gemini).',
813
-
);
814
-
return;
815
-
}
816
-
817
-
await interaction.editReply(
818
-
await client.getLocaleText('commands.ai.testing', interaction.locale),
819
-
);
820
-
const testResult = await testApiKey(apiKey, model, apiUrl);
821
-
822
-
if (!testResult.success) {
823
-
const errorMessage = await client.getLocaleText(
824
-
'commands.ai.testfailed',
825
-
interaction.locale,
826
-
);
827
-
await interaction.editReply(
828
-
errorMessage.replace('{error}', testResult.error || 'Unknown error'),
829
-
);
830
-
return;
831
-
}
832
-
833
-
await setUserApiKey(userId, apiKey, model, apiUrl);
834
-
await interaction.editReply(
835
-
await client.getLocaleText('commands.ai.testsuccess', interaction.locale),
836
-
);
837
-
838
-
if (!originalInteraction.deferred && !originalInteraction.replied) {
839
-
await originalInteraction.deferReply();
840
-
}
841
-
await processAIRequest(client, originalInteraction);
842
-
}
843
-
} catch {
844
-
await interaction.editReply({
845
-
content: await client.getLocaleText('failedrequest', interaction.locale),
846
-
});
847
-
} finally {
848
-
pendingRequests.delete(getInvokerId(interaction));
849
-
}
850
-
},
851
-
} as unknown as SlashCommandProps;
852
-
853
-
function getInvokerId(interaction: ChatInputCommandInteraction | ModalSubmitInteraction) {
854
-
if (interaction.inGuild()) {
855
-
return `${interaction.guildId}-${interaction.user.id}`;
856
-
}
857
-
return interaction.user.id;
858
-
}
1405
+
export default aiCommand as unknown as SlashCommandProps;
+424
src/commands/utilities/stocks.ts
+424
src/commands/utilities/stocks.ts
···
1
+
import {
2
+
SlashCommandBuilder,
3
+
MessageFlags,
4
+
InteractionContextType,
5
+
ApplicationIntegrationType,
6
+
EmbedBuilder,
7
+
ActionRowBuilder,
8
+
ButtonBuilder,
9
+
ButtonStyle,
10
+
AttachmentBuilder,
11
+
type MessageActionRowComponentBuilder,
12
+
} from 'discord.js';
13
+
import { SlashCommandProps } from '@/types/command';
14
+
import logger from '@/utils/logger';
15
+
import { sanitizeInput } from '@/utils/validation';
16
+
import {
17
+
getTickerOverview,
18
+
getAggregateSeries,
19
+
buildBrandingUrl,
20
+
sanitizeTickerInput,
21
+
StockTimeframe,
22
+
} from '@/services/massive';
23
+
import { renderStockCandles } from '@/utils/stockChart';
24
+
import {
25
+
createCooldownManager,
26
+
checkCooldown,
27
+
setCooldown,
28
+
createCooldownResponse,
29
+
} from '@/utils/cooldown';
30
+
import { createCommandLogger } from '@/utils/commandLogger';
31
+
import { createErrorHandler } from '@/utils/errorHandler';
32
+
import * as config from '@/config';
33
+
import BotClient from '@/services/Client';
34
+
35
+
const cooldownManager = createCooldownManager('stocks', 5000);
36
+
const commandLogger = createCommandLogger('stocks');
37
+
const errorHandler = createErrorHandler('stocks');
38
+
39
+
const DEFAULT_TIMEFRAME: StockTimeframe = '1d';
40
+
const SUPPORTED_TIMEFRAMES: StockTimeframe[] = ['1d', '5d', '1m', '3m', '1y'];
41
+
const BUTTON_PREFIX = 'stocks_tf';
42
+
const MAX_DESCRIPTION_LENGTH = 350;
43
+
44
+
const TIMEFRAME_LABEL_KEYS: Record<StockTimeframe, string> = {
45
+
'1d': 'commands.stocks.buttons.timeframes.1d',
46
+
'5d': 'commands.stocks.buttons.timeframes.5d',
47
+
'1m': 'commands.stocks.buttons.timeframes.1m',
48
+
'3m': 'commands.stocks.buttons.timeframes.3m',
49
+
'1y': 'commands.stocks.buttons.timeframes.1y',
50
+
};
51
+
52
+
const compactNumber = new Intl.NumberFormat('en-US', {
53
+
notation: 'compact',
54
+
maximumFractionDigits: 2,
55
+
});
56
+
57
+
function getCurrencyFormatter(code?: string) {
58
+
const currency = code && code.length === 3 ? code : 'USD';
59
+
try {
60
+
return new Intl.NumberFormat('en-US', {
61
+
style: 'currency',
62
+
currency,
63
+
maximumFractionDigits: currency === 'JPY' ? 0 : 2,
64
+
});
65
+
} catch {
66
+
return new Intl.NumberFormat('en-US', {
67
+
style: 'currency',
68
+
currency: 'USD',
69
+
maximumFractionDigits: 2,
70
+
});
71
+
}
72
+
}
73
+
74
+
function formatCurrency(value?: number, currency?: string) {
75
+
if (typeof value !== 'number' || Number.isNaN(value)) {
76
+
return 'โ';
77
+
}
78
+
return getCurrencyFormatter(currency).format(value);
79
+
}
80
+
81
+
function formatNumber(value?: number) {
82
+
if (typeof value !== 'number' || Number.isNaN(value)) {
83
+
return 'โ';
84
+
}
85
+
return compactNumber.format(value);
86
+
}
87
+
88
+
function truncateDescription(description?: string) {
89
+
if (!description) return undefined;
90
+
const clean = sanitizeInput(description);
91
+
if (clean.length <= MAX_DESCRIPTION_LENGTH) {
92
+
return clean;
93
+
}
94
+
return `${clean.slice(0, MAX_DESCRIPTION_LENGTH)}โฆ`;
95
+
}
96
+
97
+
function resolveCurrencyCode(value?: string) {
98
+
if (!value) return 'USD';
99
+
const normalized = value.trim().toUpperCase();
100
+
if (normalized.length === 3) {
101
+
return normalized;
102
+
}
103
+
return 'USD';
104
+
}
105
+
106
+
function toValidDate(value?: number | string | null) {
107
+
if (value === null || value === undefined) {
108
+
return undefined;
109
+
}
110
+
111
+
if (typeof value === 'number' && Number.isFinite(value)) {
112
+
const date = new Date(value);
113
+
return Number.isNaN(date.getTime()) ? undefined : date;
114
+
}
115
+
116
+
if (typeof value === 'string' && value.trim().length > 0) {
117
+
const date = new Date(value);
118
+
return Number.isNaN(date.getTime()) ? undefined : date;
119
+
}
120
+
121
+
return undefined;
122
+
}
123
+
124
+
interface StocksRenderOptions {
125
+
client: BotClient;
126
+
locale: string;
127
+
ticker: string;
128
+
timeframe: StockTimeframe;
129
+
userId: string;
130
+
}
131
+
132
+
async function buildTimeframeButtons(
133
+
client: BotClient,
134
+
locale: string,
135
+
active: StockTimeframe,
136
+
userId: string,
137
+
ticker: string,
138
+
) {
139
+
const row = new ActionRowBuilder<MessageActionRowComponentBuilder>();
140
+
141
+
for (const timeframe of SUPPORTED_TIMEFRAMES) {
142
+
const label = await client.getLocaleText(TIMEFRAME_LABEL_KEYS[timeframe], locale);
143
+
row.addComponents(
144
+
new ButtonBuilder()
145
+
.setCustomId(`${BUTTON_PREFIX}:${userId}:${ticker}:${timeframe}`)
146
+
.setLabel(label.toUpperCase())
147
+
.setStyle(timeframe === active ? ButtonStyle.Primary : ButtonStyle.Secondary),
148
+
);
149
+
}
150
+
151
+
return row;
152
+
}
153
+
154
+
export async function renderStocksView(options: StocksRenderOptions) {
155
+
const normalizedTicker = sanitizeTickerInput(options.ticker);
156
+
if (!normalizedTicker) {
157
+
const error = new Error('STOCKS_TICKER_NOT_FOUND');
158
+
throw error;
159
+
}
160
+
161
+
const overview = await getTickerOverview(normalizedTicker);
162
+
if (!overview.detail) {
163
+
const error = new Error('STOCKS_TICKER_NOT_FOUND');
164
+
throw error;
165
+
}
166
+
167
+
const aggregates = await getAggregateSeries(normalizedTicker, options.timeframe);
168
+
169
+
const detail = overview.detail;
170
+
const snapshot = overview.snapshot;
171
+
const lastPrice = snapshot?.lastTrade?.p ?? snapshot?.day?.c ?? snapshot?.prevDay?.c;
172
+
const prevClose = snapshot?.prevDay?.c;
173
+
const changeValue =
174
+
snapshot?.todaysChange ?? (lastPrice && prevClose ? lastPrice - prevClose : undefined);
175
+
const changePercent =
176
+
snapshot?.todaysChangePerc ??
177
+
(changeValue && prevClose ? (changeValue / prevClose) * 100 : undefined);
178
+
const trend =
179
+
typeof changeValue === 'number'
180
+
? changeValue === 0
181
+
? 'neutral'
182
+
: changeValue > 0
183
+
? 'up'
184
+
: 'down'
185
+
: 'neutral';
186
+
const color = trend === 'up' ? 0x1ac486 : trend === 'down' ? 0xff6b6b : 0x5865f2;
187
+
const chartBuffer = aggregates.length
188
+
? await renderStockCandles(aggregates, options.timeframe)
189
+
: undefined;
190
+
191
+
const [
192
+
priceLabel,
193
+
changeLabel,
194
+
rangeLabel,
195
+
volumeLabel,
196
+
prevCloseLabel,
197
+
marketCapLabel,
198
+
providedBy,
199
+
] = await Promise.all([
200
+
options.client.getLocaleText('commands.stocks.labels.price', options.locale),
201
+
options.client.getLocaleText('commands.stocks.labels.change', options.locale),
202
+
options.client.getLocaleText('commands.stocks.labels.dayrange', options.locale),
203
+
options.client.getLocaleText('commands.stocks.labels.volume', options.locale),
204
+
options.client.getLocaleText('commands.stocks.labels.prevclose', options.locale),
205
+
options.client.getLocaleText('commands.stocks.labels.marketcap', options.locale),
206
+
options.client.getLocaleText('providedby', options.locale),
207
+
]);
208
+
209
+
const currencySymbol = resolveCurrencyCode(detail.currency_name);
210
+
const description = truncateDescription(detail.description);
211
+
const dayLow = snapshot?.day?.l ?? snapshot?.prevDay?.l;
212
+
const dayHigh = snapshot?.day?.h ?? snapshot?.prevDay?.h;
213
+
const thumbnail = buildBrandingUrl(detail.branding?.icon_url ?? detail.branding?.logo_url);
214
+
const footerText = `${providedBy} Massive.com`;
215
+
216
+
const embed = new EmbedBuilder()
217
+
.setColor(color)
218
+
.setTitle(`${normalizedTicker} โข ${detail.name}`)
219
+
.setFooter({ text: footerText });
220
+
221
+
const timestampDate = toValidDate(snapshot?.updated);
222
+
embed.setTimestamp(timestampDate ?? new Date());
223
+
224
+
if (description) {
225
+
embed.setDescription(description);
226
+
}
227
+
228
+
if (thumbnail) {
229
+
embed.setThumbnail(thumbnail);
230
+
}
231
+
232
+
let files: AttachmentBuilder[] = [];
233
+
if (chartBuffer) {
234
+
const attachmentName = `stocks-${normalizedTicker}-${options.timeframe}.png`;
235
+
const attachment = new AttachmentBuilder(chartBuffer, { name: attachmentName });
236
+
embed.setImage(`attachment://${attachmentName}`);
237
+
files = [attachment];
238
+
} else {
239
+
embed.addFields({
240
+
name: '\u200B',
241
+
value: await options.client.getLocaleText('commands.stocks.labels.nochart', options.locale),
242
+
});
243
+
}
244
+
245
+
embed.addFields(
246
+
{
247
+
name: priceLabel,
248
+
value: formatCurrency(lastPrice, currencySymbol),
249
+
inline: true,
250
+
},
251
+
{
252
+
name: changeLabel,
253
+
value:
254
+
typeof changeValue === 'number'
255
+
? changePercent
256
+
? `${formatCurrency(changeValue, currencySymbol)} (${changePercent.toFixed(2)}%)`
257
+
: formatCurrency(changeValue, currencySymbol)
258
+
: 'โ',
259
+
inline: true,
260
+
},
261
+
{
262
+
name: rangeLabel,
263
+
value: `${formatCurrency(dayLow, currencySymbol)} - ${formatCurrency(dayHigh, currencySymbol)}`,
264
+
inline: true,
265
+
},
266
+
{
267
+
name: volumeLabel,
268
+
value: formatNumber(snapshot?.day?.v ?? snapshot?.prevDay?.v),
269
+
inline: true,
270
+
},
271
+
{
272
+
name: prevCloseLabel,
273
+
value: formatCurrency(prevClose, currencySymbol),
274
+
inline: true,
275
+
},
276
+
{
277
+
name: marketCapLabel,
278
+
value: formatNumber(detail.market_cap),
279
+
inline: true,
280
+
},
281
+
);
282
+
283
+
const buttons = await buildTimeframeButtons(
284
+
options.client,
285
+
options.locale,
286
+
options.timeframe,
287
+
options.userId,
288
+
normalizedTicker,
289
+
);
290
+
291
+
return {
292
+
embeds: [embed],
293
+
components: [buttons],
294
+
files,
295
+
};
296
+
}
297
+
298
+
export default {
299
+
data: new SlashCommandBuilder()
300
+
.setName('stocks')
301
+
.setDescription('Track stock prices and view quick charts')
302
+
.addStringOption((option) =>
303
+
option
304
+
.setName('ticker')
305
+
.setDescription('The stock ticker symbol (e.g., AAPL, TSLA)')
306
+
.setRequired(true)
307
+
.setMaxLength(15),
308
+
)
309
+
.addStringOption((option) =>
310
+
option
311
+
.setName('range')
312
+
.setDescription('Initial timeframe for the chart')
313
+
.addChoices(
314
+
{ name: '1D', value: '1d' },
315
+
{ name: '5D', value: '5d' },
316
+
{ name: '1M', value: '1m' },
317
+
{ name: '3M', value: '3m' },
318
+
{ name: '1Y', value: '1y' },
319
+
),
320
+
)
321
+
.setContexts([
322
+
InteractionContextType.BotDM,
323
+
InteractionContextType.Guild,
324
+
InteractionContextType.PrivateChannel,
325
+
])
326
+
.setIntegrationTypes(ApplicationIntegrationType.UserInstall),
327
+
328
+
async execute(client, interaction) {
329
+
try {
330
+
const cooldownCheck = await checkCooldown(
331
+
cooldownManager,
332
+
interaction.user.id,
333
+
client,
334
+
interaction.locale,
335
+
);
336
+
if (cooldownCheck.onCooldown) {
337
+
return interaction.reply(createCooldownResponse(cooldownCheck.message!));
338
+
}
339
+
340
+
if (!config.MASSIVE_API_KEY) {
341
+
const msg = await client.getLocaleText(
342
+
'commands.stocks.errors.noapikey',
343
+
interaction.locale,
344
+
);
345
+
return interaction.reply({ content: msg, flags: MessageFlags.Ephemeral });
346
+
}
347
+
348
+
setCooldown(cooldownManager, interaction.user.id);
349
+
350
+
const tickerInput = interaction.options.getString('ticker', true);
351
+
const timeframeInput =
352
+
(interaction.options.getString('range') as StockTimeframe | null) ?? DEFAULT_TIMEFRAME;
353
+
const ticker = sanitizeTickerInput(tickerInput);
354
+
355
+
if (!ticker) {
356
+
const notFound = await client.getLocaleText(
357
+
'commands.stocks.errors.notfound',
358
+
interaction.locale,
359
+
{
360
+
ticker: tickerInput,
361
+
},
362
+
);
363
+
return interaction.reply({ content: notFound, flags: MessageFlags.Ephemeral });
364
+
}
365
+
366
+
commandLogger.logFromInteraction(
367
+
interaction,
368
+
`ticker: ${ticker} timeframe: ${timeframeInput}`,
369
+
);
370
+
371
+
await interaction.deferReply();
372
+
373
+
try {
374
+
const response = await renderStocksView({
375
+
client,
376
+
locale: interaction.locale,
377
+
ticker,
378
+
timeframe: timeframeInput,
379
+
userId: interaction.user.id,
380
+
});
381
+
382
+
await interaction.editReply(response);
383
+
} catch (error) {
384
+
if ((error as Error).message === 'STOCKS_TICKER_NOT_FOUND') {
385
+
const notFound = await client.getLocaleText(
386
+
'commands.stocks.errors.notfound',
387
+
interaction.locale,
388
+
{ ticker },
389
+
);
390
+
await interaction.editReply({ content: notFound, components: [] });
391
+
return;
392
+
}
393
+
394
+
await errorHandler({
395
+
interaction,
396
+
client,
397
+
error: error as Error,
398
+
userId: interaction.user.id,
399
+
username: interaction.user.tag,
400
+
});
401
+
}
402
+
} catch (error) {
403
+
logger.error('Unexpected error in stocks command:', error);
404
+
if (!interaction.replied && !interaction.deferred) {
405
+
await interaction.reply({
406
+
content: await client.getLocaleText('unexpectederror', interaction.locale),
407
+
flags: MessageFlags.Ephemeral,
408
+
});
409
+
} else if (interaction.deferred) {
410
+
const errorMsg = await client.getLocaleText('unexpectederror', interaction.locale);
411
+
await interaction.editReply({ content: errorMsg });
412
+
}
413
+
}
414
+
},
415
+
} as SlashCommandProps;
416
+
417
+
export function parseStocksButtonId(customId: string) {
418
+
if (!customId.startsWith(`${BUTTON_PREFIX}:`)) return null;
419
+
const [, userId, ticker, timeframe] = customId.split(':');
420
+
if (!userId || !ticker || !SUPPORTED_TIMEFRAMES.includes(timeframe as StockTimeframe)) {
421
+
return null;
422
+
}
423
+
return { userId, ticker, timeframe: timeframe as StockTimeframe };
424
+
}
+2
-2
src/commands/utilities/wiki.ts
+2
-2
src/commands/utilities/wiki.ts
···
24
24
25
25
const MAX_EXTRACT_LENGTH = 2000;
26
26
27
-
async function searchWikipedia(query: string, locale = 'en') {
27
+
export async function searchWikipedia(query: string, locale = 'en') {
28
28
const wikiLang = locale.startsWith('es') ? 'es' : 'en';
29
29
const searchUrl = `https://${wikiLang}.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(query)}&format=json&srlimit=1`;
30
30
···
46
46
};
47
47
}
48
48
49
-
async function getArticleSummary(pageId: number, wikiLang = 'en') {
49
+
export async function getArticleSummary(pageId: number, wikiLang = 'en') {
50
50
const summaryUrl = `https://${wikiLang}.wikipedia.org/w/api.php?action=query&prop=extracts|pageimages&exintro&explaintext&format=json&pithumbsize=300&pageids=${pageId}`;
51
51
const response = await fetch(summaryUrl);
52
52
+4
src/config/index.ts
+4
src/config/index.ts
···
7
7
CLIENT_ID: process.env.CLIENT_ID,
8
8
DATABASE_URL: process.env.DATABASE_URL,
9
9
API_KEY_ENCRYPTION_SECRET: process.env.API_KEY_ENCRYPTION_SECRET,
10
+
CLIENT_SECRET: process.env.CLIENT_SECRET,
11
+
REDIRECT_URI: process.env.REDIRECT_URI,
10
12
};
11
13
12
14
for (const [key, value] of Object.entries(requiredEnvVars)) {
···
23
25
export const DATABASE_URL = process.env.DATABASE_URL!;
24
26
export const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
25
27
export const OPENWEATHER_API_KEY = process.env.OPENWEATHER_API_KEY;
28
+
export const MASSIVE_API_KEY = process.env.MASSIVE_API_KEY;
29
+
export const MASSIVE_API_BASE_URL = process.env.MASSIVE_API_BASE_URL ?? 'https://api.massive.com';
26
30
export const SOURCE_COMMIT = process.env.SOURCE_COMMIT;
27
31
export const TOKEN = process.env.TOKEN!;
28
32
export const CLIENT_ID = process.env.CLIENT_ID!;
+48
-1
src/events/interactionCreate.ts
+48
-1
src/events/interactionCreate.ts
···
1
1
import { browserHeaders } from '@/constants/index';
2
2
import BotClient from '@/services/Client';
3
+
import * as config from '@/config';
4
+
import { renderStocksView, parseStocksButtonId } from '@/commands/utilities/stocks';
3
5
import { RandomReddit } from '@/types/base';
4
6
import { RemindCommandProps } from '@/types/command';
5
7
import logger from '@/utils/logger';
···
89
91
if (remind && remind.handleModal) {
90
92
await remind.handleModal(this.client, i);
91
93
}
92
-
} else if (i.customId === 'apiCredentials') {
94
+
} else if (i.customId.startsWith('apiCredentials')) {
93
95
const ai = this.client.commands.get('ai');
94
96
if (ai && 'handleModal' in ai) {
95
97
await (ai as unknown as RemindCommandProps).handleModal(this.client, i);
···
127
129
}
128
130
).handleButton(this.client, i);
129
131
}
132
+
}
133
+
134
+
const stocksPayload = parseStocksButtonId(i.customId);
135
+
if (stocksPayload) {
136
+
if (!config.MASSIVE_API_KEY) {
137
+
const message = await this.client.getLocaleText(
138
+
'commands.stocks.errors.noapikey',
139
+
i.locale,
140
+
);
141
+
return await i.reply({ content: message, flags: MessageFlags.Ephemeral });
142
+
}
143
+
144
+
if (stocksPayload.userId !== i.user.id) {
145
+
const unauthorized =
146
+
(await this.client.getLocaleText('commands.stocks.errors.unauthorized', i.locale)) ||
147
+
'Only the person who used /stocks can use these buttons.';
148
+
return await i.reply({ content: unauthorized, flags: MessageFlags.Ephemeral });
149
+
}
150
+
151
+
await i.deferUpdate();
152
+
153
+
try {
154
+
const response = await renderStocksView({
155
+
client: this.client,
156
+
locale: i.locale,
157
+
ticker: stocksPayload.ticker,
158
+
timeframe: stocksPayload.timeframe,
159
+
userId: stocksPayload.userId,
160
+
});
161
+
await i.editReply(response);
162
+
} catch (error) {
163
+
if ((error as Error).message === 'STOCKS_TICKER_NOT_FOUND') {
164
+
const notFound = await this.client.getLocaleText(
165
+
'commands.stocks.errors.notfound',
166
+
i.locale,
167
+
{ ticker: stocksPayload.ticker },
168
+
);
169
+
await i.editReply({ content: notFound, components: [] });
170
+
} else {
171
+
logger.error('Error updating stocks view:', error);
172
+
const failMsg = await this.client.getLocaleText('failedrequest', i.locale);
173
+
await i.editReply({ content: failMsg, components: [] });
174
+
}
175
+
}
176
+
return;
130
177
}
131
178
132
179
const originalUser = i.message.interaction!.user;
+621
-74
src/events/messageCreate.ts
+621
-74
src/events/messageCreate.ts
···
1
-
import { Message, ChannelType } from 'discord.js';
1
+
import { Message, ChannelType, type ChatInputCommandInteraction } from 'discord.js';
2
2
import BotClient from '@/services/Client';
3
3
import logger from '@/utils/logger';
4
4
import {
5
5
makeAIRequest,
6
6
getApiConfiguration,
7
-
buildSystemPrompt,
7
+
buildSystemPrompt as originalBuildSystemPrompt,
8
8
buildConversation,
9
9
getUserCredentials,
10
10
incrementAndCheckDailyLimit,
···
12
12
splitResponseIntoChunks,
13
13
processUrls,
14
14
} from '@/commands/utilities/ai';
15
+
16
+
function buildSystemPrompt(
17
+
usingDefaultKey: boolean,
18
+
client?: BotClient,
19
+
model?: string,
20
+
username?: string,
21
+
interaction?: ChatInputCommandInteraction,
22
+
isServer?: boolean,
23
+
serverName?: string,
24
+
): string {
25
+
const basePrompt = originalBuildSystemPrompt(
26
+
usingDefaultKey,
27
+
client,
28
+
model,
29
+
username,
30
+
interaction,
31
+
isServer,
32
+
serverName,
33
+
);
34
+
35
+
const reactionInstructions = `
36
+
**AVAILABLE TOOLS:**
37
+
38
+
**REACTION TOOLS:**
39
+
- {reaction:"๐"} - React to the user's message with a unicode emoji
40
+
- {reaction:{"emoji":":thumbsup:"}} - React using a named emoji if available
41
+
- {reaction:{"emoji":"<:name:123456789012345678>"}} - React with a custom emoji by ID (or animated <a:name:id>)
42
+
43
+
**REACTION GUIDELINES:**
44
+
- When asked to react, ALWAYS use the {reaction:"emoji"} tool call
45
+
- Use reactions sparingly and only when it adds value to the conversation
46
+
- Add at most 1โ2 reactions for a single message
47
+
- Do not include the reaction tool call text in your visible reply
48
+
- Common reactions: ๐ ๐ ๐ ๐ โค๏ธ ๐ฅ โญ ๐ ๐
49
+
- Example: If asked to react with thumbs up, use {reaction:"๐"} and respond normally
50
+
- IMPORTANT: If you use a reaction tool, you MUST also provide a text response - never use ONLY a reaction tool
51
+
- The reaction tool is for adding emoji reactions, not for replacing your response
52
+
53
+
**NEW MESSAGE TOOL - CRITICAL GUIDELINES:**
54
+
**WHAT IT DOES:**
55
+
- {newmessage:} splits your response into multiple Discord messages
56
+
- This simulates how real users send follow-up messages
57
+
- Use it to break up long responses or create natural conversation flow
58
+
59
+
**WHEN TO USE IT:**
60
+
- Only when you have SUBSTANTIAL content to split (multiple paragraphs, distinct thoughts)
61
+
- When your response is naturally long and would benefit from being split
62
+
- DO NOT use it for short responses or single sentences
63
+
64
+
**HOW TO USE IT CORRECTLY:**
65
+
- Place it BETWEEN meaningful parts of your response
66
+
- You MUST have content BEFORE and AFTER the tool
67
+
- CORRECT: "Here's my first point about this topic. {newmessage:} And here's my second point that continues the thought."
68
+
- CORRECT: "Let me explain this in parts. First, the background information. {newmessage:} Now, here's how it applies to your situation."
69
+
70
+
**NEVER DO THESE - THEY ARE WRONG:**
71
+
- WRONG: "{newmessage:} Here's my response" (starts with the tool)
72
+
- WRONG: "Here's my response {newmessage:}" (ends with the tool)
73
+
- WRONG: "{newmessage:}" (tool by itself with no content)
74
+
- WRONG: Using it for responses under 200 characters
75
+
- WRONG: Using it to split single sentences or short phrases
76
+
77
+
**VALIDATION CHECK:**
78
+
- Before using {newmessage:}, ask yourself: "Do I have meaningful content both before AND after this tool?"
79
+
- If the answer is NO, don't use the tool
80
+
- If your response is short, send it as one message
81
+
- The tool should feel natural and conversational, not forced
82
+
83
+
**EXAMPLES OF PROPER USAGE:**
84
+
- "I've analyzed your code and found several issues. The first is a syntax error on line 23. {newmessage:} The second issue is a logical error in your loop condition that could cause an infinite loop."
85
+
- "Let me break down the solution for you. Step 1: Understand the problem by identifying the root cause. {newmessage:} Step 2: Implement the fix by refactoring the problematic function. {newmessage:} Step 3: Test your solution thoroughly before deploying."
86
+
87
+
**IMPORTANT DISTINCTION:**
88
+
- \`{newmessage:}\` is a FORMATTING TOOL - it just splits your response into multiple messages
89
+
- \`{newmessage:}\` does NOT execute any real functionality like other tools
90
+
- You can use \`{newmessage:}\` without any other tool calls - it's just for message formatting
91
+
- Don't expect any feedback or results from \`{newmessage:}\` - it just creates a new message
92
+
93
+
**AVOIDING TOOL LOOPS:**
94
+
- If you find yourself repeatedly trying to use tools but generating no content, STOP and respond normally
95
+
- If your tool usage isn't working as expected, provide a simple text response instead
96
+
- Never let tool usage prevent you from giving a helpful response
97
+
- When in doubt, respond with plain text rather than complex tool combinations
98
+
`;
99
+
100
+
return basePrompt + reactionInstructions;
101
+
}
102
+
103
+
import { extractToolCalls as extractSlashToolCalls } from '@/utils/commandExecutor';
104
+
import _fetch from '@/utils/dynamicFetch';
105
+
import { executeMessageToolCall, type MessageToolCall } from '@/utils/messageToolExecutor';
15
106
import type { ConversationMessage, AIResponse } from '@/commands/utilities/ai';
107
+
import pool from '@/utils/pgClient';
16
108
17
109
type ApiConfiguration = ReturnType<typeof getApiConfiguration>;
18
110
import { createMemoryManager } from '@/utils/memoryManager';
19
111
20
112
const serverConversations = createMemoryManager<string, ConversationMessage[]>({
21
-
maxSize: 1000,
113
+
maxSize: 5000,
22
114
maxAge: 2 * 60 * 60 * 1000,
23
115
cleanupInterval: 10 * 60 * 1000,
24
116
});
···
31
123
timestamp: number;
32
124
}>
33
125
>({
34
-
maxSize: 1000,
126
+
maxSize: 5000,
35
127
maxAge: 2 * 60 * 60 * 1000,
36
128
cleanupInterval: 10 * 60 * 1000,
37
129
});
···
41
133
maxAge: 2 * 60 * 60 * 1000,
42
134
cleanupInterval: 10 * 60 * 1000,
43
135
});
136
+
137
+
function extractMessageToolCalls(content: string): {
138
+
cleanContent: string;
139
+
toolCalls: MessageToolCall[];
140
+
} {
141
+
const { cleanContent, toolCalls } = extractSlashToolCalls(content);
142
+
143
+
return { cleanContent, toolCalls };
144
+
}
44
145
45
146
function getServerConversationKey(guildId: string): string {
46
147
return `server:${guildId}`;
···
114
215
model: userCustomModel,
115
216
apiKey: userApiKey,
116
217
apiUrl: userApiUrl,
117
-
} = await getUserCredentials(`user:${message.author.id}`);
218
+
} = await getUserCredentials(message.author.id);
118
219
119
220
selectedModel = hasImages
120
221
? 'google/gemma-3-4b-it'
···
123
224
config = getApiConfiguration(userApiKey ?? null, selectedModel, userApiUrl ?? null);
124
225
usingDefaultKey = config.usingDefaultKey;
125
226
} else {
126
-
selectedModel = hasImages ? 'google/gemma-3-4b-it' : 'google/gemini-2.5-flash-lite';
227
+
selectedModel = hasImages ? 'google/gemma-3-4b-it' : 'moonshotai/kimi-k2';
127
228
128
229
config = getApiConfiguration(null, selectedModel, null);
230
+
if (config.usingDefaultKey && !config.finalApiKey) {
231
+
await message.reply({
232
+
content:
233
+
'โ AI is not configured. Please set OPENROUTER_API_KEY on the bot, or use `/ai` with your own API key.',
234
+
allowedMentions: { parse: ['users'] as const },
235
+
});
236
+
return;
237
+
}
129
238
}
130
239
131
240
logger.info(
···
134
243
}`,
135
244
);
136
245
246
+
let replyContext = '';
247
+
if (message.reference?.messageId) {
248
+
try {
249
+
const repliedTo = await message.channel.messages.fetch(message.reference.messageId);
250
+
if (repliedTo) {
251
+
replyContext = `[Replying to ${repliedTo.author.username}: ${repliedTo.content}]\n\n`;
252
+
}
253
+
} catch (error) {
254
+
logger.debug('Error fetching replied message:', error);
255
+
}
256
+
}
257
+
137
258
const systemPrompt = buildSystemPrompt(
138
259
usingDefaultKey,
139
260
this.client,
···
144
265
!isDM ? message.guild?.name : undefined,
145
266
);
146
267
268
+
const baseContent = isDM ? message.content : message.content.replace(/<@!?\d+>/g, '').trim();
269
+
const messageWithContext = replyContext ? `${replyContext}${baseContent}` : baseContent;
270
+
147
271
let messageContent:
148
272
| string
149
273
| Array<{
···
153
277
url: string;
154
278
detail?: 'low' | 'high' | 'auto';
155
279
};
156
-
}> = isDM ? message.content : message.content.replace(/<@!?\d+>/g, '').trim();
280
+
}>;
157
281
158
282
if (hasImages) {
159
283
const imageAttachments = message.attachments.filter(
···
171
295
};
172
296
}> = [];
173
297
174
-
const cleanContent = isDM
175
-
? message.content
176
-
: message.content.replace(/<@!?\d+>/g, '').trim();
177
-
if (cleanContent.trim()) {
298
+
if (messageWithContext.trim()) {
178
299
contentArray.push({
179
300
type: 'text',
180
-
text: cleanContent,
301
+
text: messageWithContext,
181
302
});
182
303
}
183
304
···
192
313
});
193
314
194
315
messageContent = contentArray;
316
+
} else {
317
+
messageContent = messageWithContext;
195
318
}
196
319
197
320
let conversation: ConversationMessage[] = [];
···
244
367
245
368
const updatedConversation = buildConversation(
246
369
filteredConversation,
247
-
messageContent,
370
+
messageWithContext,
248
371
systemPrompt,
249
372
);
250
373
251
-
if (config.usingDefaultKey) {
252
-
const exemptUserId = process.env.AI_EXEMPT_USER_ID;
253
-
const actorId = message.author.id;
374
+
const exemptUserId = process.env.AI_EXEMPT_USER_ID?.trim();
375
+
const actorId = message.author.id;
376
+
const isExempt = actorId === exemptUserId;
377
+
378
+
logger.debug(
379
+
`AI limit check - usingDefaultKey: ${config.usingDefaultKey}, exemptUserId: ${exemptUserId}, actorId: ${actorId}, isExempt: ${isExempt}, isDM: ${isDM}`,
380
+
);
254
381
255
-
if (actorId !== exemptUserId) {
256
-
if (isDM) {
257
-
const allowed = await incrementAndCheckDailyLimit(actorId, 10);
258
-
if (!allowed) {
382
+
if (config.usingDefaultKey && !isExempt) {
383
+
if (isDM) {
384
+
logger.debug(`Checking DM daily limit for user ${actorId}`);
385
+
const allowed = await incrementAndCheckDailyLimit(actorId, 50);
386
+
logger.debug(`DM daily limit check result for user ${actorId}: ${allowed}`);
387
+
if (!allowed) {
388
+
await message.reply(
389
+
"โ You've reached your daily limit of 50 AI requests. " +
390
+
'Vote for Aethel to get more requests: https://top.gg/bot/1371031984230371369/vote\n' +
391
+
'Or set up your own API key using the `/ai` command.',
392
+
);
393
+
return;
394
+
}
395
+
} else {
396
+
let serverLimit = 30;
397
+
try {
398
+
const memberCount = message.guild?.memberCount || 0;
399
+
if (memberCount >= 1000) {
400
+
serverLimit = 500;
401
+
} else if (memberCount >= 100) {
402
+
serverLimit = 150;
403
+
}
404
+
405
+
const voteBonus = await pool.query(
406
+
`SELECT COUNT(DISTINCT user_id) as voter_count
407
+
FROM votes
408
+
WHERE vote_timestamp > NOW() - INTERVAL '24 hours'
409
+
AND user_id IN (
410
+
SELECT user_id FROM votes WHERE server_id IS NULL
411
+
)`,
412
+
);
413
+
414
+
const voterCount = parseInt(voteBonus.rows[0]?.voter_count || '0');
415
+
if (voterCount > 0) {
416
+
const bonus = Math.min(voterCount * 20, 100);
417
+
serverLimit += bonus;
418
+
logger.debug(
419
+
`Server ${message.guildId} vote bonus: +${bonus} (${voterCount} voters)`,
420
+
);
421
+
}
422
+
423
+
const serverAllowed = await incrementAndCheckServerDailyLimit(
424
+
message.guildId!,
425
+
serverLimit,
426
+
);
427
+
if (!serverAllowed) {
259
428
await message.reply(
260
-
"โ You've reached your daily limit of AI requests. Please try again tomorrow or set up your own API key using the `/ai` command.",
429
+
`โ This server has reached its daily limit of ${serverLimit} AI requests. ` +
430
+
`Vote for Aethel to get more requests: https://top.gg/bot/1371031984230371369/vote`,
261
431
);
262
432
return;
263
433
}
264
-
} else {
265
-
let serverLimit = 30;
266
-
try {
267
-
const memberCount = message.guild?.memberCount || 0;
268
-
if (memberCount >= 1000) {
269
-
serverLimit = 500;
270
-
} else if (memberCount >= 100) {
271
-
serverLimit = 150;
272
-
}
273
-
274
-
const serverAllowed = await incrementAndCheckServerDailyLimit(
275
-
message.guildId!,
276
-
serverLimit,
434
+
} catch (error) {
435
+
logger.error('Error checking server member count:', error);
436
+
const serverAllowed = await incrementAndCheckServerDailyLimit(message.guildId!, 30);
437
+
if (!serverAllowed) {
438
+
await message.reply(
439
+
'โ This server has reached its daily limit of AI requests. ' +
440
+
'Vote for Aethel to get more requests: https://top.gg/bot/1371031984230371369/vote',
277
441
);
278
-
if (!serverAllowed) {
279
-
await message.reply(
280
-
`โ This server has reached its daily limit of ${serverLimit} AI requests. Please try again tomorrow.`,
281
-
);
282
-
return;
283
-
}
284
-
} catch (error) {
285
-
logger.error('Error checking server member count:', error);
286
-
const serverAllowed = await incrementAndCheckServerDailyLimit(message.guildId!, 30);
287
-
if (!serverAllowed) {
288
-
await message.reply(
289
-
'โ This server has reached its daily limit of AI requests. Please try again tomorrow.',
290
-
);
291
-
return;
292
-
}
442
+
return;
293
443
}
294
444
}
295
445
}
···
298
448
return;
299
449
}
300
450
301
-
let aiResponse = await makeAIRequest(config, updatedConversation);
451
+
const conversationWithTools = [...updatedConversation];
452
+
const executedResults: Array<{ type: string; payload: Record<string, unknown> }> = [];
453
+
let aiResponse = await makeAIRequest(config, conversationWithTools);
302
454
303
455
if (!aiResponse && hasImages) {
304
456
logger.warn(`First attempt failed for ${selectedModel}, retrying once...`);
305
457
await new Promise((resolve) => setTimeout(resolve, 1000));
306
-
aiResponse = await makeAIRequest(config, updatedConversation);
458
+
aiResponse = await makeAIRequest(config, conversationWithTools);
307
459
}
308
460
309
461
if (!aiResponse && hasImages) {
310
462
logger.warn(`Image model ${selectedModel} failed, falling back to text-only model`);
311
463
312
-
let fallbackContent = message.content;
464
+
let fallbackContent = messageWithContext;
313
465
if (Array.isArray(messageContent)) {
314
466
const textParts = messageContent
315
-
.filter((item) => item.type === 'text')
316
-
.map((item) => item.text)
317
-
.filter((text) => text && text.trim());
467
+
.filter((item: { type: string; text?: string }) => item.type === 'text')
468
+
.map((item: { type: string; text?: string }) => item.text)
469
+
.filter((text: string | undefined) => text && text.trim());
318
470
319
471
const imageParts = messageContent
320
-
.filter((item) => item.type === 'image_url')
321
-
.map((item) => `[Image: ${item.image_url?.url}]`);
472
+
.filter(
473
+
(item: { type: string; image_url?: { url: string } }) => item.type === 'image_url',
474
+
)
475
+
.map(
476
+
(item: { type: string; image_url?: { url: string } }) =>
477
+
`[Image: ${item.image_url?.url}]`,
478
+
);
322
479
323
480
fallbackContent =
324
481
[...textParts, ...imageParts].join(' ') ||
···
365
522
}
366
523
}
367
524
525
+
const maxIterations = 3;
526
+
let iteration = 0;
527
+
let lastToolResponse = '';
528
+
let originalContentWithTools = aiResponse?.content || '';
529
+
530
+
while (aiResponse && iteration < maxIterations) {
531
+
iteration++;
532
+
const extraction = extractMessageToolCalls(aiResponse.content || '');
533
+
const toolCalls: MessageToolCall[] = extraction.toolCalls;
534
+
535
+
const executableTools = toolCalls.filter((tc) => tc.name?.toLowerCase() !== 'newmessage');
536
+
const reactionTools = executableTools.filter((tc) => tc.name?.toLowerCase() === 'reaction');
537
+
const nonReactionTools = executableTools.filter(
538
+
(tc) => tc.name?.toLowerCase() !== 'reaction',
539
+
);
540
+
541
+
if (executableTools.length > 0 && nonReactionTools.length === 0) {
542
+
logger.debug(
543
+
`AI used only reactions (${reactionTools.length}), breaking loop and preserving tool calls`,
544
+
);
545
+
originalContentWithTools = aiResponse.content || '';
546
+
aiResponse.content = extraction.cleanContent;
547
+
break;
548
+
}
549
+
550
+
if (executableTools.length === 0) {
551
+
aiResponse.content = extraction.cleanContent;
552
+
break;
553
+
}
554
+
555
+
const currentToolResponse = JSON.stringify(
556
+
executableTools.map((tc) => ({ name: tc.name, args: tc.args })),
557
+
);
558
+
if (currentToolResponse === lastToolResponse) {
559
+
logger.warn('AI stuck in tool loop, breaking out to prevent [NO CONTENT] responses');
560
+
aiResponse.content =
561
+
extraction.cleanContent ||
562
+
'I apologize, but I seem to be having trouble with the tools. Let me respond normally.';
563
+
break;
564
+
}
565
+
lastToolResponse = currentToolResponse;
566
+
567
+
conversationWithTools.push({ role: 'assistant', content: aiResponse.content });
568
+
569
+
for (const tc of nonReactionTools) {
570
+
const name = tc.name?.toLowerCase();
571
+
try {
572
+
const result = await executeMessageToolCall(tc, message, this.client, {
573
+
originalMessage: message,
574
+
botMessage: undefined,
575
+
});
576
+
const payload = {
577
+
type: name,
578
+
success: result.success,
579
+
handled: result.handled,
580
+
error: result.error || null,
581
+
...(result.result?.metadata || {}),
582
+
};
583
+
584
+
executedResults.push({ type: name, payload });
585
+
conversationWithTools.push({ role: 'user', content: JSON.stringify(payload) });
586
+
587
+
logger.debug(`[MessageCreate] MCP tool ${name} executed:`, {
588
+
success: result.success,
589
+
handled: result.handled,
590
+
});
591
+
} catch (e) {
592
+
conversationWithTools.push({ role: 'user', content: `[Tool ${tc.name} error]` });
593
+
logger.error('[MessageCreate] Tool execution threw exception', {
594
+
name: tc.name,
595
+
error: (e as Error)?.message,
596
+
});
597
+
}
598
+
}
599
+
600
+
aiResponse = await makeAIRequest(config, conversationWithTools);
601
+
if (!aiResponse) break;
602
+
603
+
const hasNewMessageTool = toolCalls.some((tc) => tc.name?.toLowerCase() === 'newmessage');
604
+
const cleanContent = extraction.cleanContent?.trim() || '';
605
+
606
+
if (hasNewMessageTool && !cleanContent && iteration >= 2) {
607
+
logger.warn('AI stuck in newmessage misuse loop, forcing normal response');
608
+
aiResponse.content =
609
+
'I apologize for the confusion. Let me respond clearly without using any tools.';
610
+
break;
611
+
}
612
+
613
+
if (nonReactionTools.length === 0 && hasNewMessageTool) {
614
+
logger.debug('Only newmessage formatting tools found, breaking iterative loop');
615
+
aiResponse.content = extraction.cleanContent;
616
+
break;
617
+
}
618
+
}
619
+
368
620
if (!aiResponse) {
369
621
await message.reply({
370
622
content: 'Sorry, I encountered an error processing your message. Please try again later.',
···
373
625
return;
374
626
}
375
627
628
+
if (!aiResponse.content || !aiResponse.content.trim()) {
629
+
const last = executedResults[executedResults.length - 1];
630
+
if (last) {
631
+
if (
632
+
(last.type === 'cat' || last.type === 'dog') &&
633
+
typeof last.payload.url === 'string'
634
+
) {
635
+
aiResponse.content = `Here you go ${last.type === 'cat' ? '๐ฑ' : '๐ถ'}: ${last.payload.url}`;
636
+
} else if (last.type === 'weather') {
637
+
const p = last.payload as Record<string, string>;
638
+
if (p.location && p.temperature) {
639
+
aiResponse.content = `Weather for ${p.location}: ${p.temperature} (feels ${p.feels_like}), ${p.conditions}. Humidity ${p.humidity}, Wind ${p.wind_speed}, Pressure ${p.pressure}.`;
640
+
} else if (p.description) {
641
+
aiResponse.content = `Weather in ${p.location || 'the requested area'}: ${p.description}`;
642
+
}
643
+
} else if (last.type === 'wiki') {
644
+
const p = last.payload as Record<string, string>;
645
+
if (p.title || p.extract || p.url) {
646
+
aiResponse.content =
647
+
`${p.title || ''}\n${p.extract || ''}\n${p.url ? `<${p.url}>` : ''}`.trim();
648
+
}
649
+
}
650
+
}
651
+
652
+
if (!aiResponse.content || !aiResponse.content.trim()) {
653
+
logger.debug('AI response has no meaningful content, not sending message');
654
+
return;
655
+
}
656
+
}
657
+
376
658
aiResponse.content = processUrls(aiResponse.content);
377
659
aiResponse.content = aiResponse.content.replace(/@(everyone|here)/gi, '@\u200b$1');
378
660
661
+
const originalContent = originalContentWithTools || aiResponse.content || '';
662
+
const extraction = extractMessageToolCalls(originalContent);
663
+
aiResponse.content = extraction.cleanContent;
664
+
const toolCalls: MessageToolCall[] = extraction.toolCalls;
665
+
const hasReactionTool = toolCalls.some((tc) => tc?.name?.toLowerCase() === 'reaction');
666
+
const originalCleaned = (extraction.cleanContent || '').trim();
667
+
668
+
logger.debug(`Final tool extraction: ${toolCalls.length} tools found`, {
669
+
tools: toolCalls.map((tc) => tc.name),
670
+
hasReaction: hasReactionTool,
671
+
cleanContent: extraction.cleanContent?.substring(0, 50),
672
+
});
673
+
674
+
if (!originalCleaned && hasReactionTool) {
675
+
for (const tc of toolCalls) {
676
+
if (!tc || !tc.name) continue;
677
+
const name = tc.name.toLowerCase();
678
+
if (name !== 'reaction') continue;
679
+
try {
680
+
await executeMessageToolCall(tc, message, this.client, {
681
+
originalMessage: message,
682
+
botMessage: undefined,
683
+
});
684
+
} catch (err) {
685
+
logger.error('Error executing reaction tool on original message:', err);
686
+
}
687
+
}
688
+
return;
689
+
}
690
+
379
691
const { getUnallowedWordCategory } = await import('@/utils/validation');
380
692
const category = getUnallowedWordCategory(aiResponse.content);
381
693
if (category) {
···
388
700
return;
389
701
}
390
702
391
-
await this.sendResponse(message, aiResponse);
703
+
const cleaned = (aiResponse.content || '').trim();
704
+
const onlyReactions = !cleaned && hasReactionTool;
705
+
if (onlyReactions) {
706
+
const toolCallRegex = /{([^{}\s:]+):({[^{}]*}|[^{}]*)?}/g;
707
+
const fallback = originalContent.replace(toolCallRegex, '').trim();
708
+
if (fallback) {
709
+
aiResponse.content = fallback;
710
+
} else {
711
+
const textParts: string[] = [];
712
+
for (const tc of toolCalls) {
713
+
if (!tc || !tc.args) continue;
714
+
const a = tc.args as Record<string, unknown>;
715
+
const candidates = ['text', 'query', 'content', 'body', 'message'];
716
+
for (const k of candidates) {
717
+
const v = a[k] as unknown;
718
+
if (typeof v === 'string' && v.trim()) {
719
+
textParts.push(v.trim());
720
+
break;
721
+
}
722
+
}
723
+
}
724
+
if (textParts.length) {
725
+
aiResponse.content = textParts.join(' ');
726
+
} else {
727
+
const reactionLabels: string[] = [];
728
+
for (const tc of toolCalls) {
729
+
if (!tc || !tc.name) continue;
730
+
if (tc.name.toLowerCase() !== 'reaction') continue;
731
+
const a = tc.args as Record<string, unknown>;
732
+
const emojiCandidate =
733
+
(a.emoji as string) || (a.query as string) || (a['emojiRaw'] as string) || '';
734
+
if (typeof emojiCandidate === 'string' && emojiCandidate.trim()) {
735
+
reactionLabels.push(emojiCandidate.trim());
736
+
}
737
+
}
738
+
if (reactionLabels.length) {
739
+
aiResponse.content = `Reacted with ${reactionLabels.join(', ')}`;
740
+
} else {
741
+
aiResponse.content = 'Reacted.';
742
+
}
743
+
}
744
+
}
745
+
}
746
+
747
+
const sent = await this.sendResponse(message, aiResponse, executedResults);
748
+
const sentMessage: Message | undefined = sent as Message | undefined;
749
+
750
+
if (extraction.toolCalls.length > 0) {
751
+
const executed: Array<{ name: string; success: boolean }> = [];
752
+
logger.debug(
753
+
`[MessageCreate] Final execution - processing ${extraction.toolCalls.length} tool calls:`,
754
+
extraction.toolCalls.map((tc) => ({ name: tc.name, args: tc.args })),
755
+
);
756
+
for (const tc of extraction.toolCalls) {
757
+
if (!tc || !tc.name) continue;
758
+
const name = tc.name.toLowerCase();
759
+
if (name === 'reaction') {
760
+
try {
761
+
const result = await executeMessageToolCall(tc, message, this.client, {
762
+
originalMessage: message,
763
+
botMessage: sentMessage,
764
+
});
765
+
executed.push({ name, success: !!result?.success });
766
+
} catch (err) {
767
+
logger.error('Error executing message tool:', { name, err });
768
+
executed.push({ name, success: false });
769
+
}
770
+
} else {
771
+
const target = sentMessage || message;
772
+
try {
773
+
const result = await executeMessageToolCall(tc, target, this.client, {
774
+
originalMessage: message,
775
+
botMessage: sentMessage,
776
+
});
777
+
778
+
if (name === 'cat' || name === 'dog') {
779
+
const imageUrl = result.result?.metadata?.url as string;
780
+
if (imageUrl && imageUrl.startsWith('http')) {
781
+
await target.reply({ content: '', files: [imageUrl] });
782
+
}
783
+
} else if (name === 'weather' || name === 'wiki') {
784
+
const textContent = result.result?.content?.find((c) => c.type === 'text')?.text;
785
+
if (textContent) {
786
+
await target.reply(processUrls(textContent));
787
+
}
788
+
}
789
+
790
+
executed.push({ name, success: result.success });
791
+
} catch (err) {
792
+
logger.error(`Error executing MCP tool ${name}:`, { err });
793
+
executed.push({ name, success: false });
794
+
}
795
+
}
796
+
}
797
+
798
+
if (onlyReactions) {
799
+
const anyFailed = executed.some((e) => e.name === 'reaction' && !e.success);
800
+
if (anyFailed && sentMessage) {
801
+
try {
802
+
await sentMessage.edit(
803
+
'I tried to react, but I do not have permission to add reactions here or the emoji was invalid.',
804
+
);
805
+
} catch (e) {
806
+
logger.error('Failed to edit placeholder message after reaction failure:', e);
807
+
}
808
+
}
809
+
}
810
+
}
392
811
393
812
const userMessage: ConversationMessage = {
394
813
role: 'user',
395
-
content: messageContent,
814
+
content: messageWithContext,
396
815
username: message.author.username,
397
816
};
398
817
const assistantMessage: ConversationMessage = {
···
413
832
414
833
logger.info(`${isDM ? 'DM' : 'Server'} response sent successfully`);
415
834
} catch (error) {
416
-
logger.error(
417
-
`Error processing ${isDM ? 'DM' : 'server message'}:`,
418
-
error instanceof Error ? error.message : String(error),
419
-
);
835
+
const err = error as Error;
836
+
logger.error(`Error processing ${isDM ? 'DM' : 'server message'}:`, {
837
+
message: err?.message,
838
+
stack: err?.stack,
839
+
raw: error,
840
+
});
420
841
try {
421
842
await message.reply(
422
843
'Sorry, I encountered an error processing your message. Please try again later.',
···
427
848
}
428
849
}
429
850
430
-
private async sendResponse(message: Message, aiResponse: AIResponse): Promise<void> {
851
+
private async sendResponse(
852
+
message: Message,
853
+
aiResponse: AIResponse,
854
+
executedResults?: Array<{ type: string; payload: Record<string, unknown> }>,
855
+
): Promise<Message | void> {
431
856
let fullResponse = '';
432
857
433
858
if (aiResponse.reasoning) {
···
435
860
}
436
861
437
862
fullResponse += aiResponse.content;
863
+
fullResponse = processUrls(fullResponse);
438
864
439
-
const maxLength = 2000;
440
-
if (fullResponse.length <= maxLength) {
441
-
await message.reply({
442
-
content: fullResponse,
865
+
const imageFiles: string[] = [];
866
+
if (executedResults) {
867
+
for (const result of executedResults) {
868
+
if (
869
+
(result.type === 'cat' || result.type === 'dog') &&
870
+
result.payload.url &&
871
+
typeof result.payload.url === 'string'
872
+
) {
873
+
imageFiles.push(result.payload.url);
874
+
}
875
+
}
876
+
}
877
+
878
+
if (!fullResponse || !fullResponse.trim()) {
879
+
logger.debug('AI response has no meaningful content, not sending message');
880
+
return;
881
+
}
882
+
883
+
const newMessageOnlyRegex = /^\s*\{newmessage:\}\s*$/;
884
+
if (newMessageOnlyRegex.test(fullResponse)) {
885
+
logger.warn('AI misused newmessage tool - sent only {newmessage:} with no content');
886
+
return;
887
+
}
888
+
889
+
const newMessageRegex = /\{newmessage:\}/g;
890
+
if (newMessageRegex.test(fullResponse)) {
891
+
const parts = fullResponse.split(/\{newmessage:\}/);
892
+
893
+
if (parts[0].trim() === '') {
894
+
logger.warn('AI misused newmessage tool - started response with {newmessage:}');
895
+
parts.shift();
896
+
if (parts.length === 0) {
897
+
return await message.reply({
898
+
content: fullResponse.replace(/\{newmessage:\}/g, '').trim() || '\u200b',
899
+
allowedMentions: { parse: ['users'] as const },
900
+
});
901
+
}
902
+
}
903
+
904
+
const first = await message.reply({
905
+
content: parts[0].trim() || '\u200b',
906
+
files: imageFiles.length > 0 ? imageFiles : undefined,
443
907
allowedMentions: { parse: ['users'] as const },
444
908
});
445
-
} else {
446
-
const chunks = splitResponseIntoChunks(fullResponse, maxLength);
447
909
448
-
await message.reply({ content: chunks[0], allowedMentions: { parse: ['users'] as const } });
910
+
for (let i = 1; i < parts.length; i++) {
911
+
if ('send' in message.channel && parts[i].trim()) {
912
+
const delay = Math.floor(Math.random() * 900) + 300;
913
+
await new Promise((resolve) => setTimeout(resolve, delay));
449
914
450
-
for (let i = 1; i < chunks.length; i++) {
451
-
if ('send' in message.channel) {
452
915
await message.channel.send({
453
-
content: chunks[i],
916
+
content: parts[i].trim(),
454
917
allowedMentions: { parse: ['users'] as const },
455
918
});
456
919
}
920
+
}
921
+
return first;
922
+
}
923
+
const conversationChunks = this.splitIntoConversationalChunks(fullResponse);
924
+
925
+
const first = await message.reply({
926
+
content: conversationChunks[0],
927
+
files: imageFiles.length > 0 ? imageFiles : undefined,
928
+
allowedMentions: { parse: ['users'] as const },
929
+
});
930
+
931
+
for (let i = 1; i < conversationChunks.length; i++) {
932
+
if ('send' in message.channel) {
933
+
const delay = Math.floor(Math.random() * 900) + 300;
934
+
await new Promise((resolve) => setTimeout(resolve, delay));
935
+
936
+
await message.channel.send({
937
+
content: conversationChunks[i],
938
+
allowedMentions: { parse: ['users'] as const },
939
+
});
457
940
}
458
941
}
942
+
return first;
943
+
}
944
+
945
+
private splitIntoConversationalChunks(text: string): string[] {
946
+
if (!text || text.length <= 200) {
947
+
return [text];
948
+
}
949
+
950
+
const chunks: string[] = [];
951
+
const paragraphs = text.split(/\n\n+/);
952
+
953
+
for (const paragraph of paragraphs) {
954
+
if (paragraph.length < 200) {
955
+
chunks.push(paragraph);
956
+
} else {
957
+
const sentences = paragraph.split(/(?<=[.!?])\s+/);
958
+
959
+
let currentChunk = '';
960
+
for (const sentence of sentences) {
961
+
if (currentChunk.length + sentence.length > 200 && currentChunk.length > 0) {
962
+
chunks.push(currentChunk);
963
+
currentChunk = sentence;
964
+
} else {
965
+
if (currentChunk && !currentChunk.endsWith('\n')) {
966
+
currentChunk += ' ';
967
+
}
968
+
currentChunk += sentence;
969
+
}
970
+
971
+
const hasEndPunctuation = /[.!?]$/.test(sentence);
972
+
const breakChance = hasEndPunctuation ? 0.7 : 0.3;
973
+
974
+
if (currentChunk.length > 100 && Math.random() < breakChance) {
975
+
chunks.push(currentChunk);
976
+
currentChunk = '';
977
+
}
978
+
}
979
+
980
+
if (currentChunk) {
981
+
chunks.push(currentChunk);
982
+
}
983
+
}
984
+
}
985
+
986
+
const fillerMessages = ['hmm', 'let me think', 'one sec', 'actually', 'wait', 'so basically'];
987
+
988
+
if (chunks.length > 1 && Math.random() < 0.3) {
989
+
const position = Math.floor(Math.random() * (chunks.length - 1)) + 1;
990
+
const filler = fillerMessages[Math.floor(Math.random() * fillerMessages.length)];
991
+
chunks.splice(position, 0, filler);
992
+
}
993
+
994
+
const maxLength = 2000;
995
+
const finalChunks: string[] = [];
996
+
997
+
for (const chunk of chunks) {
998
+
if (chunk.length <= maxLength) {
999
+
finalChunks.push(chunk);
1000
+
} else {
1001
+
finalChunks.push(...splitResponseIntoChunks(chunk, maxLength));
1002
+
}
1003
+
}
1004
+
1005
+
return finalChunks;
459
1006
}
460
1007
}
+11
-2
src/events/ready.ts
+11
-2
src/events/ready.ts
···
3
3
import { loadActiveReminders } from '@/commands/utilities/remind';
4
4
5
5
export default class ReadyEvent {
6
-
constructor(c: BotClient) {
7
-
c.once('ready', () => this.readyEvent(c));
6
+
private startTime: number;
7
+
8
+
constructor(c: BotClient, startTime: number = Date.now()) {
9
+
this.startTime = startTime;
10
+
c.once('clientReady', () => this.readyEvent(c));
8
11
}
9
12
10
13
private async readyEvent(client: BotClient) {
11
14
try {
12
15
logger.info(`Logged in as ${client.user?.username}`);
16
+
13
17
await client.application?.commands.fetch({ withLocalizations: true });
14
18
15
19
await loadActiveReminders(client);
20
+
21
+
const { sendDeploymentNotification } = await import('../utils/sendDeploymentNotification.js');
22
+
await sendDeploymentNotification(this.startTime);
23
+
24
+
logger.info('Bot fully initialized and ready');
16
25
} catch (error) {
17
26
logger.error('Error during ready event:', error);
18
27
}
+1
-1
src/handlers/initialzeCommands.ts
+1
-1
src/handlers/initialzeCommands.ts
···
33
33
await import(commandUrl)
34
34
).default) as SlashCommandProps | RemindCommandProps;
35
35
if (!command.data) {
36
-
console.log('No command data in file', `${cat}/${file}.. Skipping`);
36
+
logger.warn('No command data in file', `${cat}/${file}.. Skipping`);
37
37
continue;
38
38
}
39
39
command.category = cat;
+27
-9
src/index.ts
+27
-9
src/index.ts
···
2
2
import e from 'express';
3
3
import helmet from 'helmet';
4
4
import cors from 'cors';
5
-
5
+
import path from 'path';
6
+
import { fileURLToPath } from 'url';
6
7
import BotClient from './services/Client';
7
8
import { ALLOWED_ORIGINS, PORT, RATE_LIMIT_WINDOW_MS, RATE_LIMIT_MAX } from './config';
8
9
import rateLimit from 'express-rate-limit';
9
10
import authenticateApiKey from './middlewares/verifyApiKey';
11
+
import { authenticateToken } from './middlewares/auth';
10
12
import status from './routes/status';
11
13
import authRoutes from './routes/auth';
12
14
import todosRoutes from './routes/todos';
13
15
import apiKeysRoutes from './routes/apiKeys';
14
16
import remindersRoutes from './routes/reminders';
17
+
import voteWebhookRoutes from './routes/voteWebhook';
15
18
import { resetOldStrikes } from './utils/userStrikes';
16
19
import logger from './utils/logger';
17
20
···
28
31
29
32
const app = e();
30
33
const startTime = Date.now();
34
+
const __filename = fileURLToPath(import.meta.url);
35
+
const __dirname = path.dirname(__filename);
36
+
const distPath = path.resolve(__dirname, '../web/dist');
31
37
32
38
app.use(helmet());
33
39
app.use(
···
75
81
76
82
const bot = new BotClient();
77
83
bot.init();
84
+
85
+
app.use(async (req, res, next) => {
86
+
const start = process.hrtime.bigint();
87
+
res.on('finish', () => {
88
+
const durMs = Number(process.hrtime.bigint() - start) / 1e6;
89
+
const safePath = req.baseUrl ? `${req.baseUrl}${req.path}` : req.path;
90
+
logger.debug(`API [${req.method}] ${safePath} ${res.statusCode} ${durMs.toFixed(1)}ms`);
91
+
});
92
+
next();
93
+
});
78
94
79
95
app.use('/api/auth', authRoutes);
80
96
app.use('/api/todos', todosRoutes);
81
97
app.use('/api/user/api-keys', apiKeysRoutes);
82
-
app.use('/api/reminders', remindersRoutes);
98
+
app.use('/api/reminders', authenticateToken, remindersRoutes);
99
+
app.use('/api', voteWebhookRoutes);
83
100
84
101
app.use('/api/status', authenticateApiKey, status(bot));
85
102
···
87
104
res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() });
88
105
});
89
106
90
-
app.use(e.static('web/dist'));
107
+
app.use(e.static(distPath, { index: false, maxAge: '1h' }));
91
108
92
-
app.get('*', (req, res) => {
93
-
if (req.path.startsWith('/api/')) {
94
-
return res.status(404).json({ error: 'Not found' });
95
-
}
96
-
res.sendFile('index.html', { root: 'web/dist' });
109
+
app.get(/^\/(?!api\/).*/, (req, res) => {
110
+
return res.sendFile(path.join(distPath, 'index.html'));
111
+
});
112
+
113
+
app.use((req, res) => {
114
+
return res.status(404).json({ status: 404, message: 'Not Found' });
97
115
});
98
116
99
117
setInterval(
···
106
124
const server = app.listen(PORT, async () => {
107
125
logger.debug('Aethel is live on', `http://localhost:${PORT}`);
108
126
109
-
const { sendDeploymentNotification } = await import('./utils/sendDeploymentNotification');
127
+
const { sendDeploymentNotification } = await import('./utils/sendDeploymentNotification.js');
110
128
await sendDeploymentNotification(startTime);
111
129
});
112
130
+6
-20
src/middlewares/auth.ts
+6
-20
src/middlewares/auth.ts
···
2
2
import jwt from 'jsonwebtoken';
3
3
import logger from '../utils/logger';
4
4
5
-
const JWT_SECRET = process.env.JWT_SECRET || 'your-jwt-secret';
5
+
if (!process.env.JWT_SECRET) {
6
+
throw new Error('JWT_SECRET environment variable is required');
7
+
}
8
+
9
+
const JWT_SECRET = process.env.JWT_SECRET;
6
10
7
11
interface JwtPayload {
8
12
userId: string;
···
22
26
}
23
27
24
28
try {
25
-
const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload;
29
+
const decoded = jwt.verify(token, JWT_SECRET) as unknown as JwtPayload;
26
30
req.user = decoded;
27
31
next();
28
32
} catch (error) {
···
37
41
return res.status(500).json({ error: 'Token verification failed' });
38
42
}
39
43
};
40
-
41
-
export const optionalAuth = (req: Request, res: Response, next: NextFunction) => {
42
-
const authHeader = req.headers['authorization'];
43
-
const token = authHeader && authHeader.split(' ')[1];
44
-
45
-
if (!token) {
46
-
return next();
47
-
}
48
-
49
-
try {
50
-
const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload;
51
-
req.user = decoded;
52
-
} catch (error) {
53
-
logger.debug('Optional auth token verification failed:', error);
54
-
}
55
-
56
-
next();
57
-
};
+1
-1
src/middlewares/verifyApiKey.ts
+1
-1
src/middlewares/verifyApiKey.ts
···
1
1
import * as config from '@/config';
2
2
import { RequestHandler } from 'express';
3
3
4
-
const authenticateApiKey: RequestHandler = (req, res, next) => {
4
+
export const authenticateApiKey: RequestHandler = (req, res, next) => {
5
5
const apiKey = req.headers['x-api-key'];
6
6
if (!apiKey || typeof apiKey !== 'string') {
7
7
res.status(401).json({ error: 'Unauthorized: Missing API key' });
+120
-26
src/routes/apiKeys.ts
+120
-26
src/routes/apiKeys.ts
···
1
1
import { Router } from 'express';
2
+
import axios from 'axios';
2
3
import pool from '../utils/pgClient';
3
4
import logger from '../utils/logger';
4
5
import { authenticateToken } from '../middlewares/auth';
···
11
12
'openrouter.ai',
12
13
'generativelanguage.googleapis.com',
13
14
'api.anthropic.com',
15
+
'api.mistral.ai',
16
+
'api.deepseek.com',
17
+
'api.together.xyz',
18
+
'api.perplexity.ai',
19
+
'api.groq.com',
20
+
'api.lepton.ai',
21
+
'api.deepinfra.com',
22
+
'api.moonshot.ai',
23
+
'api.x.ai',
14
24
];
15
25
16
26
function getOpenAIClient(apiKey: string, baseURL?: string): OpenAI {
···
250
260
});
251
261
}
252
262
253
-
const testModel = model || 'openai/gpt-4o-mini';
254
-
const client = getOpenAIClient(apiKey, fullApiUrl);
263
+
const isGemini = parsedUrl.hostname === 'generativelanguage.googleapis.com';
264
+
265
+
if (isGemini) {
266
+
const listModelsUrl = `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`;
267
+
268
+
try {
269
+
const listResponse = await axios.get(listModelsUrl, {
270
+
headers: { 'Content-Type': 'application/json' },
271
+
timeout: 10000,
272
+
});
255
273
256
-
try {
257
-
const response = await client.chat.completions.create({
258
-
model: testModel,
259
-
messages: [
274
+
interface ModelInfo {
275
+
name: string;
276
+
supportedGenerationMethods?: string[];
277
+
[key: string]: unknown;
278
+
}
279
+
280
+
const availableModels: ModelInfo[] = listResponse.data?.models || [];
281
+
const workingModel = availableModels.find((m) =>
282
+
m.supportedGenerationMethods?.includes('generateContent'),
283
+
);
284
+
285
+
if (!workingModel) {
286
+
throw new Error('No models found that support generateContent');
287
+
}
288
+
289
+
const testPrompt =
290
+
'Hello! This is a test message. Please respond with "API key test successful!"';
291
+
const generateUrl = `https://generativelanguage.googleapis.com/v1beta/${workingModel.name}:generateContent?key=${apiKey}`;
292
+
293
+
const response = await axios.post(
294
+
generateUrl,
260
295
{
261
-
role: 'user',
262
-
content:
263
-
'Hello! This is a test message. Please respond with "API key test successful!"',
296
+
contents: [
297
+
{
298
+
role: 'user',
299
+
parts: [
300
+
{
301
+
text: testPrompt,
302
+
},
303
+
],
304
+
},
305
+
],
264
306
},
265
-
],
266
-
max_tokens: 50,
267
-
temperature: 0.1,
268
-
});
307
+
{
308
+
headers: { 'Content-Type': 'application/json' },
309
+
timeout: 10000,
310
+
},
311
+
);
269
312
270
-
const testMessage = response.choices?.[0]?.message?.content || 'Test completed';
313
+
const testMessage =
314
+
response.data?.candidates?.[0]?.content?.parts?.[0]?.text || 'Test completed';
271
315
272
-
logger.info(`API key test successful for user ${userId}`);
273
-
res.json({
274
-
success: true,
275
-
message: 'API key is valid and working!',
276
-
testResponse: testMessage,
277
-
});
278
-
} catch (error: unknown) {
279
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
280
-
logger.warn(`API key test failed for user ${userId}: ${errorMessage}`);
281
-
return res.status(400).json({
282
-
error: `API key test failed: ${errorMessage}`,
283
-
});
316
+
logger.info(
317
+
`Gemini API key test successful for user ${userId} using model ${workingModel.name}`,
318
+
);
319
+
return res.json({
320
+
success: true,
321
+
message: 'Gemini API key is valid and working!',
322
+
testResponse: testMessage,
323
+
model: workingModel.name.split('/').pop(),
324
+
});
325
+
} catch (error: unknown) {
326
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
327
+
const response =
328
+
error && typeof error === 'object' && 'response' in error
329
+
? (error as { response?: { status?: number; data?: unknown } }).response
330
+
: undefined;
331
+
332
+
logger.warn(`Gemini API key test failed for user ${userId}:`, {
333
+
error: errorMessage,
334
+
status: response?.status,
335
+
data: response?.data,
336
+
});
337
+
return res.status(400).json({
338
+
error: `Gemini API key test failed: ${errorMessage}`,
339
+
details:
340
+
response?.data && typeof response.data === 'object' && response.data !== null
341
+
? (response.data as { error?: { details?: unknown } }).error?.details
342
+
: undefined,
343
+
});
344
+
}
345
+
} else {
346
+
const testModel = model || 'gpt-5-nano';
347
+
const client = getOpenAIClient(apiKey, fullApiUrl);
348
+
349
+
try {
350
+
const response = await client.chat.completions.create({
351
+
model: testModel,
352
+
messages: [
353
+
{
354
+
role: 'user',
355
+
content:
356
+
'Hello! This is a test message. Please respond with "API key test successful!"',
357
+
},
358
+
],
359
+
max_tokens: 50,
360
+
temperature: 0.1,
361
+
});
362
+
363
+
const testMessage = response.choices?.[0]?.message?.content || 'Test completed';
364
+
365
+
logger.info(`API key test successful for user ${userId}`);
366
+
return res.json({
367
+
success: true,
368
+
message: 'API key is valid and working!',
369
+
testResponse: testMessage,
370
+
});
371
+
} catch (error: unknown) {
372
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
373
+
logger.warn(`API key test failed for user ${userId}: ${errorMessage}`);
374
+
return res.status(400).json({
375
+
error: `API key test failed: ${errorMessage}`,
376
+
});
377
+
}
284
378
}
285
379
} catch (error) {
286
380
logger.error('Error testing API key:', error);
+6
-7
src/routes/auth.ts
+6
-7
src/routes/auth.ts
···
6
6
7
7
const router = Router();
8
8
9
-
const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID;
10
-
const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET;
11
-
const DISCORD_REDIRECT_URI =
12
-
process.env.DISCORD_REDIRECT_URI || 'http://localhost:8080/api/auth/discord/callback';
9
+
const DISCORD_CLIENT_ID = process.env.CLIENT_ID;
10
+
const DISCORD_CLIENT_SECRET = process.env.CLIENT_SECRET;
11
+
const DISCORD_REDIRECT_URI = process.env.REDIRECT_URI;
13
12
const JWT_SECRET = process.env.JWT_SECRET || 'your-jwt-secret';
14
-
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:2020';
13
+
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
15
14
16
15
interface DiscordUser {
17
16
id: string;
···
22
21
}
23
22
24
23
router.get('/discord', (req, res) => {
25
-
const discordAuthUrl = `https://discord.com/api/oauth2/authorize?client_id=${DISCORD_CLIENT_ID}&redirect_uri=${encodeURIComponent(DISCORD_REDIRECT_URI)}&response_type=code&scope=identify`;
24
+
const discordAuthUrl = `https://discord.com/api/oauth2/authorize?client_id=${DISCORD_CLIENT_ID}&redirect_uri=${encodeURIComponent(DISCORD_REDIRECT_URI!)}&response_type=code&scope=identify`;
26
25
res.redirect(discordAuthUrl);
27
26
});
28
27
···
50
49
client_secret: DISCORD_CLIENT_SECRET!,
51
50
grant_type: 'authorization_code',
52
51
code: code as string,
53
-
redirect_uri: DISCORD_REDIRECT_URI,
52
+
redirect_uri: DISCORD_REDIRECT_URI!,
54
53
}),
55
54
});
56
55
+96
src/routes/voteWebhook.ts
+96
src/routes/voteWebhook.ts
···
1
+
import { Router } from 'express';
2
+
import { recordVote } from '../utils/voteManager';
3
+
import logger from '../utils/logger';
4
+
5
+
const router = Router();
6
+
7
+
interface TopGGWebhookPayload {
8
+
bot: string;
9
+
user: string;
10
+
type: 'upvote' | 'test';
11
+
isWeekend?: boolean;
12
+
query?: string;
13
+
}
14
+
15
+
router.get('/webhooks/topgg', (_, res) => {
16
+
return res.status(200).json({ status: 'ok', message: 'Webhook endpoint is active' });
17
+
});
18
+
router.post('/webhooks/topgg', async (req, res) => {
19
+
const authHeader = req.headers.authorization;
20
+
21
+
if (!authHeader || authHeader !== process.env.TOPGG_WEBHOOK_AUTH) {
22
+
logger.warn('Unauthorized webhook attempt', {
23
+
ip: req.ip,
24
+
headers: req.headers,
25
+
timestamp: new Date().toISOString(),
26
+
});
27
+
return res.status(401).json({ error: 'Unauthorized' });
28
+
}
29
+
30
+
try {
31
+
const payload = req.body as TopGGWebhookPayload;
32
+
33
+
logger.info('Received Top.gg webhook', {
34
+
type: payload.type,
35
+
userId: payload.user,
36
+
botId: payload.bot,
37
+
isWeekend: payload.isWeekend || false,
38
+
query: payload.query,
39
+
ip: req.ip,
40
+
timestamp: new Date().toISOString(),
41
+
});
42
+
43
+
if (payload.type !== 'test' && payload.type !== 'upvote') {
44
+
logger.warn('Received unknown webhook type', { type: payload.type });
45
+
return res.status(400).json({ success: false, message: 'Invalid webhook type' });
46
+
}
47
+
48
+
const userId = payload.user;
49
+
const isTest = payload.type === 'test';
50
+
51
+
logger.info(`Processing ${isTest ? 'test ' : ''}vote`, {
52
+
userId,
53
+
isTest,
54
+
isWeekend: payload.isWeekend || false,
55
+
});
56
+
57
+
const result = await recordVote(userId);
58
+
59
+
logger.info('Processed vote', {
60
+
userId,
61
+
creditsAwarded: result.creditsAwarded,
62
+
nextVote: result.nextVoteAvailable.toISOString(),
63
+
isWeekend: payload.isWeekend || false,
64
+
});
65
+
66
+
if (!result.success) {
67
+
return res.status(200).json({
68
+
success: false,
69
+
message: 'Vote already processed',
70
+
nextVote: result.nextVoteAvailable.toISOString(),
71
+
});
72
+
}
73
+
74
+
return res.status(200).json({
75
+
success: true,
76
+
message: 'Vote processed successfully',
77
+
creditsAwarded: result.creditsAwarded,
78
+
isWeekend: payload.isWeekend || false,
79
+
});
80
+
} catch (error: unknown) {
81
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
82
+
const errorStack = error instanceof Error ? error.stack : undefined;
83
+
84
+
logger.error('Error processing webhook:', {
85
+
error: errorMessage,
86
+
stack: errorStack,
87
+
headers: req.headers,
88
+
body: req.body,
89
+
ip: req.ip,
90
+
timestamp: new Date().toISOString(),
91
+
});
92
+
return res.status(500).json({ success: false, message: 'Internal server error' });
93
+
}
94
+
});
95
+
96
+
export default router;
+41
-3
src/services/Client.ts
+41
-3
src/services/Client.ts
···
21
21
// eslint-disable-next-line @typescript-eslint/no-explicit-any
22
22
public t = new Collection<string, any>();
23
23
public socialMediaManager?: SocialMediaManager;
24
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
25
+
public lastModalRawByUser: Map<string, any> = new Map();
24
26
25
27
constructor() {
26
28
super({
···
41
43
},
42
44
});
43
45
BotClient.instance = this;
46
+
47
+
interface RawPacket {
48
+
t: string;
49
+
d?: {
50
+
type: number;
51
+
member?: {
52
+
user: {
53
+
id: string;
54
+
};
55
+
};
56
+
user?: {
57
+
id: string;
58
+
};
59
+
};
60
+
}
61
+
62
+
this.on('raw', (packet: RawPacket) => {
63
+
try {
64
+
if (packet?.t !== 'INTERACTION_CREATE') return;
65
+
const d = packet.d;
66
+
if (d?.type !== 5) return;
67
+
const userId = d?.member?.user?.id || d?.user?.id;
68
+
if (!userId) return;
69
+
this.lastModalRawByUser.set(userId, d);
70
+
setTimeout(
71
+
() => {
72
+
if (this.lastModalRawByUser.get(userId) === d) {
73
+
this.lastModalRawByUser.delete(userId);
74
+
}
75
+
},
76
+
5 * 60 * 1000,
77
+
);
78
+
} catch (e) {
79
+
logger.warn('Failed to capture raw modal payload', e);
80
+
}
81
+
});
44
82
}
45
83
46
84
public static getInstance(): BotClient | null {
···
56
94
}
57
95
58
96
private async setupEvents() {
59
-
console.log('Initializing events...');
97
+
logger.info('Initializing events...');
60
98
const eventsDir = path.join(srcDir, 'events');
61
99
for (const event of readdirSync(path.join(eventsDir))) {
62
100
const filepath = path.join(eventsDir, event);
···
127
165
128
166
const shutdown = async (signal?: NodeJS.Signals) => {
129
167
try {
130
-
console.log(`Received ${signal ?? 'shutdown'}: closing services and database pool...`);
168
+
logger.info(`Received ${signal ?? 'shutdown'}: closing services and database pool...`);
131
169
await this.socialMediaManager?.cleanup();
132
170
await pool.end();
133
-
console.log('Database pool closed. Exiting.');
171
+
logger.info('Database pool closed. Exiting.');
134
172
} catch (e) {
135
173
console.error('Error during graceful shutdown:', e);
136
174
} finally {
+278
src/services/massive.ts
+278
src/services/massive.ts
···
1
+
import {
2
+
DefaultApi,
3
+
GetStocksAggregatesSortEnum,
4
+
GetStocksAggregatesTimespanEnum,
5
+
GetStocksSnapshotTicker200Response,
6
+
GetStocksSnapshotTicker200ResponseAllOfTicker,
7
+
GetTicker200ResponseResults,
8
+
GetStocksAggregates200Response,
9
+
ListTickers200ResponseResultsInner,
10
+
ListTickersMarketEnum,
11
+
ListTickersOrderEnum,
12
+
ListTickersSortEnum,
13
+
restClient,
14
+
} from '@massive.com/client-js';
15
+
import * as config from '@/config';
16
+
import logger from '@/utils/logger';
17
+
import { createRateLimiter } from '@/utils/rateLimiter';
18
+
import type { AxiosError } from 'axios';
19
+
20
+
const MASSIVE_RATE_LIMIT = 45;
21
+
const rateLimiter = createRateLimiter(MASSIVE_RATE_LIMIT);
22
+
23
+
let cachedClient: DefaultApi | null = null;
24
+
25
+
export type StockTimeframe = '1d' | '5d' | '1m' | '3m' | '1y';
26
+
27
+
interface TimeframeConfig {
28
+
multiplier: number;
29
+
timespan: GetStocksAggregatesTimespanEnum;
30
+
daysBack: number;
31
+
limit: number;
32
+
displayWindowMs?: number;
33
+
}
34
+
35
+
const TIMEFRAME_CONFIG: Record<StockTimeframe, TimeframeConfig> = {
36
+
'1d': {
37
+
multiplier: 5,
38
+
timespan: GetStocksAggregatesTimespanEnum.Minute,
39
+
daysBack: 3,
40
+
limit: 400,
41
+
displayWindowMs: 36 * 60 * 60 * 1000,
42
+
},
43
+
'5d': {
44
+
multiplier: 15,
45
+
timespan: GetStocksAggregatesTimespanEnum.Minute,
46
+
daysBack: 7,
47
+
limit: 500,
48
+
displayWindowMs: 7 * 24 * 60 * 60 * 1000,
49
+
},
50
+
'1m': {
51
+
multiplier: 1,
52
+
timespan: GetStocksAggregatesTimespanEnum.Day,
53
+
daysBack: 40,
54
+
limit: 120,
55
+
},
56
+
'3m': {
57
+
multiplier: 1,
58
+
timespan: GetStocksAggregatesTimespanEnum.Day,
59
+
daysBack: 110,
60
+
limit: 200,
61
+
},
62
+
'1y': {
63
+
multiplier: 1,
64
+
timespan: GetStocksAggregatesTimespanEnum.Week,
65
+
daysBack: 400,
66
+
limit: 400,
67
+
},
68
+
};
69
+
70
+
const DAY_MS = 24 * 60 * 60 * 1000;
71
+
72
+
export interface StockAggregatePoint {
73
+
timestamp: number;
74
+
open: number;
75
+
high: number;
76
+
low: number;
77
+
close: number;
78
+
volume: number;
79
+
vwap?: number;
80
+
}
81
+
82
+
export interface StockOverview {
83
+
detail?: GetTicker200ResponseResults;
84
+
snapshot?: GetStocksSnapshotTicker200ResponseAllOfTicker;
85
+
}
86
+
87
+
function ensureClient(): DefaultApi {
88
+
if (!config.MASSIVE_API_KEY) {
89
+
throw new Error('Massive.com API key is not configured');
90
+
}
91
+
92
+
if (!cachedClient) {
93
+
cachedClient = restClient(config.MASSIVE_API_KEY, config.MASSIVE_API_BASE_URL, {
94
+
pagination: false,
95
+
});
96
+
}
97
+
98
+
return cachedClient;
99
+
}
100
+
101
+
async function withClient<T>(callback: (client: DefaultApi) => Promise<T>): Promise<T> {
102
+
const client = ensureClient();
103
+
return rateLimiter.schedule(() => callback(client));
104
+
}
105
+
106
+
function isNotFoundError(error: unknown): boolean {
107
+
return (
108
+
typeof error === 'object' &&
109
+
error !== null &&
110
+
'isAxiosError' in error &&
111
+
(error as AxiosError).response?.status === 404
112
+
);
113
+
}
114
+
115
+
export async function searchTickers(
116
+
query: string,
117
+
limit = 5,
118
+
): Promise<ListTickers200ResponseResultsInner[]> {
119
+
if (!query.trim()) {
120
+
return [];
121
+
}
122
+
123
+
const response = await withClient((client) =>
124
+
client.listTickers(
125
+
undefined,
126
+
undefined,
127
+
ListTickersMarketEnum.Stocks,
128
+
undefined,
129
+
undefined,
130
+
undefined,
131
+
undefined,
132
+
query,
133
+
true,
134
+
undefined,
135
+
undefined,
136
+
undefined,
137
+
undefined,
138
+
ListTickersOrderEnum.Asc,
139
+
limit,
140
+
ListTickersSortEnum.Ticker,
141
+
),
142
+
);
143
+
144
+
return response.results ?? [];
145
+
}
146
+
147
+
export async function getTickerDetails(
148
+
ticker: string,
149
+
): Promise<GetTicker200ResponseResults | null> {
150
+
const normalized = ticker.trim().toUpperCase();
151
+
try {
152
+
const response = await withClient((client) => client.getTicker(normalized));
153
+
return response.results ?? null;
154
+
} catch (error) {
155
+
if (isNotFoundError(error)) {
156
+
return null;
157
+
}
158
+
throw error;
159
+
}
160
+
}
161
+
162
+
export async function getTickerSnapshot(
163
+
ticker: string,
164
+
): Promise<GetStocksSnapshotTicker200ResponseAllOfTicker | undefined> {
165
+
const normalized = ticker.trim().toUpperCase();
166
+
try {
167
+
const response: GetStocksSnapshotTicker200Response = await withClient((client) =>
168
+
client.getStocksSnapshotTicker(normalized),
169
+
);
170
+
return response.ticker;
171
+
} catch (error) {
172
+
if (isNotFoundError(error)) {
173
+
return undefined;
174
+
}
175
+
throw error;
176
+
}
177
+
}
178
+
179
+
export async function getTickerOverview(ticker: string): Promise<StockOverview> {
180
+
const [detail, snapshot] = await Promise.all([
181
+
getTickerDetails(ticker),
182
+
getTickerSnapshot(ticker),
183
+
]);
184
+
185
+
return { detail: detail ?? undefined, snapshot };
186
+
}
187
+
188
+
function formatDate(date: Date): string {
189
+
return date.toISOString().split('T')[0];
190
+
}
191
+
192
+
export async function getAggregateSeries(
193
+
ticker: string,
194
+
timeframe: StockTimeframe,
195
+
): Promise<StockAggregatePoint[]> {
196
+
const config = TIMEFRAME_CONFIG[timeframe];
197
+
if (!config) {
198
+
throw new Error(`Unsupported timeframe: ${timeframe}`);
199
+
}
200
+
201
+
const now = new Date();
202
+
const fetchAggregates = async (extraDays: number) => {
203
+
const fromDate = new Date(now.getTime() - (config.daysBack + extraDays) * DAY_MS);
204
+
return withClient((client) =>
205
+
client.getStocksAggregates(
206
+
ticker.trim().toUpperCase(),
207
+
config.multiplier,
208
+
config.timespan,
209
+
formatDate(fromDate),
210
+
formatDate(now),
211
+
true,
212
+
GetStocksAggregatesSortEnum.Asc,
213
+
config.limit,
214
+
),
215
+
);
216
+
};
217
+
218
+
try {
219
+
let response: GetStocksAggregates200Response = await fetchAggregates(0);
220
+
221
+
if ((!response.results || response.results.length === 0) && timeframe === '1d') {
222
+
response = await fetchAggregates(5);
223
+
}
224
+
225
+
const rawResults = response.results ?? [];
226
+
if (!rawResults.length) {
227
+
return [];
228
+
}
229
+
230
+
let filteredResults = rawResults.filter(
231
+
(result) => typeof result.t === 'number' && typeof result.c === 'number',
232
+
);
233
+
234
+
if (config.displayWindowMs && filteredResults.length) {
235
+
const latestTimestamp = filteredResults[filteredResults.length - 1].t!;
236
+
const cutoff = latestTimestamp - config.displayWindowMs;
237
+
filteredResults = filteredResults.filter((result) => result.t! >= cutoff);
238
+
}
239
+
240
+
return filteredResults.map((result) => ({
241
+
timestamp: result.t!,
242
+
open: result.o ?? result.c ?? 0,
243
+
high: result.h ?? result.c ?? 0,
244
+
low: result.l ?? result.c ?? 0,
245
+
close: result.c ?? 0,
246
+
volume: result.v ?? 0,
247
+
vwap: result.vw,
248
+
}));
249
+
} catch (error) {
250
+
if (isNotFoundError(error)) {
251
+
throw new Error('STOCKS_TICKER_NOT_FOUND');
252
+
}
253
+
throw error;
254
+
}
255
+
}
256
+
257
+
export function buildBrandingUrl(url?: string): string | undefined {
258
+
if (!url) return undefined;
259
+
260
+
try {
261
+
const parsed = new URL(url);
262
+
if (!parsed.searchParams.has('apiKey') && config.MASSIVE_API_KEY) {
263
+
parsed.searchParams.set('apiKey', config.MASSIVE_API_KEY);
264
+
}
265
+
return parsed.toString();
266
+
} catch (error) {
267
+
logger.warn('Failed to parse branding URL', { url, error });
268
+
return undefined;
269
+
}
270
+
}
271
+
272
+
export function sanitizeTickerInput(input: string): string {
273
+
return input
274
+
.trim()
275
+
.toUpperCase()
276
+
.replace(/[^A-Z0-9.-]/g, '')
277
+
.slice(0, 12);
278
+
}
+2
-2
src/types/base.ts
+2
-2
src/types/base.ts
+224
src/types/componentsV2.ts
+224
src/types/componentsV2.ts
···
1
+
export type V2ComponentType = 3 | 4 | 10 | 12 | 18;
2
+
3
+
export interface V2BaseComponent {
4
+
type: V2ComponentType;
5
+
}
6
+
7
+
export interface V2StringSelect extends V2BaseComponent {
8
+
type: 3;
9
+
custom_id: string;
10
+
placeholder?: string;
11
+
options: Array<{
12
+
label: string;
13
+
value: string;
14
+
description?: string;
15
+
emoji?: { id?: string; name?: string; animated?: boolean };
16
+
}>;
17
+
min_values?: number;
18
+
max_values?: number;
19
+
}
20
+
21
+
export interface V2TextInput extends V2BaseComponent {
22
+
type: 4;
23
+
custom_id: string;
24
+
style: 1 | 2;
25
+
label?: string;
26
+
placeholder?: string;
27
+
required?: boolean;
28
+
min_length?: number;
29
+
max_length?: number;
30
+
value?: string;
31
+
}
32
+
33
+
export interface V2LabeledComponent extends V2BaseComponent {
34
+
type: 18;
35
+
label?: string;
36
+
description?: string;
37
+
component: V2StringSelect | V2TextInput;
38
+
}
39
+
40
+
export type V2ModalRow =
41
+
| V2LabeledComponent
42
+
| { type: 1; components: V2TextInput[] }
43
+
| { type: 18; components: V2TextInput[] };
44
+
45
+
export interface V2SubmissionValueMap {
46
+
[customId: string]: string | string[];
47
+
}
48
+
49
+
export interface V2ModalPayload {
50
+
custom_id: string;
51
+
title: string;
52
+
components: V2ModalRow[];
53
+
}
54
+
55
+
interface V2Component {
56
+
type: number;
57
+
custom_id?: string;
58
+
customId?: string;
59
+
value?: string | string[];
60
+
values?: string[];
61
+
[key: string]: unknown;
62
+
}
63
+
64
+
interface V2Row {
65
+
component?: V2Component;
66
+
components?: V2Component[];
67
+
type?: number;
68
+
[key: string]: unknown;
69
+
}
70
+
71
+
export function buildProviderModal(customId: string, title: string): V2ModalPayload {
72
+
return {
73
+
custom_id: customId,
74
+
title,
75
+
components: [
76
+
{
77
+
type: 18,
78
+
label: 'AI Provider',
79
+
description: 'Select an authorized provider',
80
+
component: {
81
+
type: 3,
82
+
custom_id: 'provider',
83
+
placeholder: 'Choose provider',
84
+
options: [
85
+
{ label: 'OpenAI', value: 'openai', description: 'api.openai.com' },
86
+
{ label: 'Anthropic', value: 'anthropic', description: 'api.anthropic.com' },
87
+
{ label: 'OpenRouter', value: 'openrouter', description: 'openrouter.ai' },
88
+
{
89
+
label: 'Google Gemini',
90
+
value: 'gemini',
91
+
description: 'generativelanguage.googleapis.com',
92
+
},
93
+
{ label: 'DeepSeek', value: 'deepseek', description: 'api.deepseek.com' },
94
+
{ label: 'Moonshot AI', value: 'moonshot', description: 'api.moonshot.ai' },
95
+
{ label: 'Perplexity AI', value: 'perplexity', description: 'api.perplexity.ai' },
96
+
],
97
+
},
98
+
},
99
+
{
100
+
type: 1 as const,
101
+
components: [
102
+
{
103
+
type: 4,
104
+
custom_id: 'model',
105
+
label: 'Model',
106
+
style: 1,
107
+
required: true,
108
+
placeholder: 'openai/gpt-4o-mini',
109
+
min_length: 2,
110
+
max_length: 100,
111
+
},
112
+
],
113
+
},
114
+
{
115
+
type: 1 as const,
116
+
components: [
117
+
{
118
+
type: 4,
119
+
custom_id: 'apiKey',
120
+
label: 'API Key',
121
+
style: 1,
122
+
required: true,
123
+
placeholder: 'sk-... or other',
124
+
min_length: 10,
125
+
max_length: 500,
126
+
},
127
+
],
128
+
},
129
+
],
130
+
};
131
+
}
132
+
133
+
interface RawModalSubmission {
134
+
fields?: {
135
+
getTextInputValue?: (id: string) => string;
136
+
[key: string]: unknown;
137
+
};
138
+
data?: {
139
+
components?: Array<{
140
+
component?: V2Component;
141
+
components?: V2Component[];
142
+
[key: string]: unknown;
143
+
}>;
144
+
[key: string]: unknown;
145
+
};
146
+
components?: Array<{
147
+
component?: V2Component;
148
+
components?: V2Component[];
149
+
[key: string]: unknown;
150
+
}>;
151
+
message?: {
152
+
components?: Array<{
153
+
component?: V2Component;
154
+
components?: V2Component[];
155
+
[key: string]: unknown;
156
+
}>;
157
+
[key: string]: unknown;
158
+
};
159
+
[key: string]: unknown;
160
+
}
161
+
162
+
export function parseV2ModalSubmission(raw: RawModalSubmission): V2SubmissionValueMap {
163
+
const result: V2SubmissionValueMap = {};
164
+
try {
165
+
const fields = raw?.fields as { getTextInputValue?: (id: string) => string } | undefined;
166
+
if (fields?.getTextInputValue) {
167
+
for (const id of ['model', 'apiKey']) {
168
+
try {
169
+
const v = fields.getTextInputValue(id);
170
+
if (v !== undefined && v !== '') result[id] = v;
171
+
} catch (error) {
172
+
console.error(`Error getting text input value for ${id}:`, error);
173
+
}
174
+
}
175
+
}
176
+
} catch (error) {
177
+
console.error('Error processing text input fields:', error);
178
+
}
179
+
180
+
try {
181
+
const mergedRows = (
182
+
[
183
+
...(raw?.data?.components || []),
184
+
...(raw?.components || []),
185
+
...(raw?.message?.components || []),
186
+
] as Array<V2Row | undefined>
187
+
).filter(Boolean) as V2Row[];
188
+
189
+
const flat = mergedRows.flatMap((r) => (r.component ? [r.component] : r.components || []));
190
+
191
+
for (const c of flat) {
192
+
if (!c) continue;
193
+
194
+
if (c.customId === 'provider' || c.custom_id === 'provider') {
195
+
if (Array.isArray((c as { values?: string[] }).values)) {
196
+
result.provider = (c as { values: string[] }).values;
197
+
} else if ((c as { value?: string | string[] }).value) {
198
+
const value = (c as { value: string | string[] }).value;
199
+
result.provider = Array.isArray(value) ? value : [value];
200
+
}
201
+
}
202
+
203
+
if (c.type === 4 && (c.custom_id || c.customId) && (c as { value?: unknown }).value) {
204
+
const value = (c as { value: unknown }).value;
205
+
if (typeof value === 'string') {
206
+
result[c.custom_id || c.customId!] = value;
207
+
}
208
+
}
209
+
}
210
+
} catch (error) {
211
+
console.error('Error processing component values:', error);
212
+
}
213
+
return result;
214
+
}
215
+
216
+
export const PROVIDER_TO_URL: Record<string, string> = {
217
+
openai: 'https://api.openai.com/v1',
218
+
anthropic: 'https://api.anthropic.com/v1',
219
+
openrouter: 'https://openrouter.ai/api/v1',
220
+
gemini: 'https://generativelanguage.googleapis.com',
221
+
deepseek: 'https://api.deepseek.com',
222
+
moonshot: 'https://api.moonshot.ai',
223
+
perplexity: 'https://api.perplexity.ai',
224
+
};
+692
src/utils/commandExecutor.ts
+692
src/utils/commandExecutor.ts
···
1
+
import {
2
+
ChatInputCommandInteraction,
3
+
InteractionReplyOptions,
4
+
MessagePayload,
5
+
EmbedBuilder,
6
+
} from 'discord.js';
7
+
import { SlashCommandProps } from '@/types/command';
8
+
import BotClient from '@/services/Client';
9
+
import logger from '@/utils/logger';
10
+
11
+
export interface ToolCall {
12
+
name: string;
13
+
args: Record<string, unknown>;
14
+
}
15
+
16
+
type EmbedInput = Record<string, unknown> & {
17
+
data?: Record<string, unknown>;
18
+
toJSON?: () => unknown;
19
+
title?: unknown;
20
+
description?: unknown;
21
+
fields?: unknown;
22
+
color?: unknown;
23
+
timestamp?: unknown;
24
+
footer?: unknown;
25
+
};
26
+
27
+
function toPlainEmbedObject(embed: unknown): Record<string, unknown> | unknown {
28
+
if (embed && typeof embed === 'object') {
29
+
const embedObj = embed as EmbedInput;
30
+
if ('data' in embedObj && embedObj.data) {
31
+
return embedObj.data as Record<string, unknown>;
32
+
}
33
+
if (typeof embedObj.toJSON === 'function') {
34
+
return embedObj.toJSON();
35
+
}
36
+
const e = embedObj as Record<string, unknown>;
37
+
if ('title' in e || 'fields' in e || 'description' in e) {
38
+
return e;
39
+
}
40
+
return {
41
+
title: embedObj.title,
42
+
description: embedObj.description,
43
+
fields: embedObj.fields,
44
+
color: embedObj.color,
45
+
timestamp: embedObj.timestamp,
46
+
footer: embedObj.footer,
47
+
} as Record<string, unknown>;
48
+
}
49
+
return embed as Record<string, unknown>;
50
+
}
51
+
52
+
function testEmbedBuilderStructure() {
53
+
const testEmbed = new EmbedBuilder()
54
+
.setTitle('Test Title')
55
+
.setDescription('Test Description')
56
+
.addFields({ name: 'Test Field', value: 'Test Value', inline: true });
57
+
58
+
logger.debug(`[Command Executor] Test EmbedBuilder structure:`, {
59
+
embed: testEmbed,
60
+
hasData: 'data' in testEmbed,
61
+
data: testEmbed.data,
62
+
hasToJSON: typeof testEmbed.toJSON === 'function',
63
+
toJSON: testEmbed.toJSON ? testEmbed.toJSON() : 'N/A',
64
+
keys: Object.keys(testEmbed),
65
+
prototype: Object.getPrototypeOf(testEmbed)?.constructor?.name,
66
+
});
67
+
68
+
if (testEmbed.data) {
69
+
logger.debug(`[Command Executor] Test embed data fields:`, {
70
+
fields: testEmbed.data.fields,
71
+
fieldsLength: testEmbed.data.fields?.length,
72
+
allDataKeys: Object.keys(testEmbed.data),
73
+
});
74
+
}
75
+
}
76
+
77
+
export function extractToolCalls(content: string): { cleanContent: string; toolCalls: ToolCall[] } {
78
+
const toolCallRegex = /{([^{}\s:]+):({[^{}]*}|[^{}]*)?}/g;
79
+
const toolCalls: ToolCall[] = [];
80
+
let cleanContent = content;
81
+
let match;
82
+
83
+
while ((match = toolCallRegex.exec(content)) !== null) {
84
+
try {
85
+
if (!match[1]) {
86
+
continue;
87
+
}
88
+
89
+
const toolName = match[1].trim();
90
+
const argsString = match[2] ? match[2].trim() : '';
91
+
92
+
if (!toolName) {
93
+
continue;
94
+
}
95
+
96
+
let args: Record<string, unknown> = {};
97
+
98
+
if (argsString.startsWith('{') && argsString.endsWith('}')) {
99
+
try {
100
+
args = JSON.parse(argsString);
101
+
} catch (_error) {
102
+
args = { query: argsString };
103
+
}
104
+
} else if (argsString) {
105
+
if (argsString.startsWith('"') && argsString.endsWith('"')) {
106
+
const unquoted = argsString.slice(1, -1);
107
+
if (toolName === 'reaction') {
108
+
args = { emoji: unquoted };
109
+
} else {
110
+
args = { query: unquoted };
111
+
}
112
+
} else {
113
+
args = { query: argsString };
114
+
}
115
+
} else {
116
+
args = {};
117
+
}
118
+
119
+
toolCalls.push({
120
+
name: toolName,
121
+
args,
122
+
});
123
+
124
+
cleanContent = cleanContent.replace(match[0], '').trim();
125
+
} catch (error) {
126
+
logger.error(`Error parsing tool call: ${error}`);
127
+
}
128
+
}
129
+
130
+
return { cleanContent, toolCalls };
131
+
}
132
+
133
+
export async function executeToolCall(
134
+
toolCall: ToolCall,
135
+
interaction: ChatInputCommandInteraction,
136
+
client: BotClient,
137
+
): Promise<string> {
138
+
let { name, args } = toolCall;
139
+
140
+
if (name.includes(':')) {
141
+
const parts = name.split(':');
142
+
name = parts[0];
143
+
if (!args || Object.keys(args).length === 0) {
144
+
args = { search: parts[1] };
145
+
}
146
+
}
147
+
148
+
try {
149
+
const validCommands = ['cat', 'dog', 'joke', '8ball', 'weather', 'wiki'];
150
+
151
+
if (!validCommands.includes(name.toLowerCase())) {
152
+
throw new Error(
153
+
`Command '${name}' is not a valid command. Available commands: ${validCommands.join(', ')}`,
154
+
);
155
+
}
156
+
157
+
if (['cat', 'dog'].includes(name)) {
158
+
const commandName = name.charAt(0).toUpperCase() + name.slice(1);
159
+
logger.debug(`[${commandName}] Starting ${name} command execution`);
160
+
161
+
try {
162
+
const commandDir = 'fun';
163
+
const commandModule = await import(`../commands/${commandDir}/${name}`);
164
+
165
+
const imageData =
166
+
name === 'cat'
167
+
? await commandModule.fetchCatImage()
168
+
: await commandModule.fetchDogImage();
169
+
170
+
return JSON.stringify({
171
+
success: true,
172
+
type: name,
173
+
title: imageData.title || `Random ${commandName}`,
174
+
url: imageData.url,
175
+
subreddit: imageData.subreddit,
176
+
source: name === 'cat' ? 'pur.cat' : 'erm.dog',
177
+
handled: true,
178
+
});
179
+
} catch (error) {
180
+
const errorMessage =
181
+
error instanceof Error ? error.message : `Unknown error in ${name} command`;
182
+
logger.error(`[${commandName}] Error: ${errorMessage}`, { error });
183
+
return JSON.stringify({
184
+
success: false,
185
+
error: `Failed to execute ${name} command: ${errorMessage}`,
186
+
handled: false,
187
+
});
188
+
}
189
+
}
190
+
191
+
if (name === 'wiki') {
192
+
logger.debug('[Wiki] Starting wiki command execution');
193
+
194
+
try {
195
+
const wikiModule = await import('../commands/utilities/wiki');
196
+
197
+
let searchQuery = '';
198
+
if (typeof args === 'object' && args !== null) {
199
+
const argsObj = args as Record<string, unknown>;
200
+
searchQuery = (argsObj.search as string) || (argsObj.query as string) || '';
201
+
}
202
+
203
+
if (!searchQuery) {
204
+
return JSON.stringify({
205
+
error: true,
206
+
message: 'Missing search query',
207
+
status: 400,
208
+
});
209
+
}
210
+
211
+
try {
212
+
logger.debug(
213
+
`[Wiki] Searching Wikipedia for: ${searchQuery} (locale: ${interaction.locale || 'en'})`,
214
+
);
215
+
216
+
const searchResult = await wikiModule.searchWikipedia(
217
+
searchQuery,
218
+
interaction.locale || 'en',
219
+
);
220
+
logger.debug(`[Wiki] Search result:`, {
221
+
pageid: searchResult.pageid,
222
+
title: searchResult.title,
223
+
});
224
+
225
+
const article = await wikiModule.getArticleSummary(
226
+
searchResult.pageid,
227
+
searchResult.wikiLang,
228
+
);
229
+
logger.debug(`[Wiki] Retrieved article:`, {
230
+
title: article.title,
231
+
extractLength: article.extract?.length,
232
+
});
233
+
234
+
const maxLength = 1500;
235
+
const truncated = article.extract && article.extract.length > maxLength;
236
+
const extract = truncated
237
+
? article.extract.substring(0, maxLength)
238
+
: article.extract || 'No summary available for this article.';
239
+
240
+
const response = {
241
+
title: article.title,
242
+
content: extract,
243
+
url: `https://${searchResult.wikiLang}.wikipedia.org/wiki/${encodeURIComponent(article.title.replace(/ /g, '_'))}`,
244
+
truncated: truncated,
245
+
};
246
+
247
+
return JSON.stringify(response);
248
+
} catch (error) {
249
+
const errorMessage = error instanceof Error ? error.message : String(error);
250
+
const stack = error instanceof Error ? error.stack : undefined;
251
+
const responseStatus =
252
+
error instanceof Error && 'response' in error
253
+
? (error as { response?: { status?: number } }).response?.status
254
+
: undefined;
255
+
256
+
logger.error('[Wiki] Error executing wiki command:', {
257
+
error: errorMessage,
258
+
stack,
259
+
responseStatus,
260
+
searchQuery,
261
+
locale: interaction.locale,
262
+
});
263
+
264
+
return JSON.stringify({
265
+
error: true,
266
+
message:
267
+
responseStatus === 404
268
+
? 'No Wikipedia article found for that search query. Please try a different search term.'
269
+
: `Error searching Wikipedia: ${errorMessage}`,
270
+
status: responseStatus || 500,
271
+
});
272
+
}
273
+
} catch (error) {
274
+
const errorMessage = error instanceof Error ? error.message : String(error);
275
+
const stack = error instanceof Error ? error.stack : undefined;
276
+
logger.error('[Wiki] Error in wiki command execution:', { error: errorMessage, stack });
277
+
278
+
return JSON.stringify({
279
+
error: true,
280
+
message: `Error in wiki command execution: ${errorMessage}`,
281
+
status: 500,
282
+
});
283
+
}
284
+
}
285
+
286
+
let commandModule;
287
+
const isDev = process.env.NODE_ENV !== 'production';
288
+
const ext = isDev ? '.ts' : '.js';
289
+
290
+
try {
291
+
let commandDir = 'fun';
292
+
if (
293
+
['weather', 'wiki', 'ai', 'cobalt', 'remind', 'social', 'time', 'todo', 'whois'].includes(
294
+
name,
295
+
)
296
+
) {
297
+
commandDir = 'utilities';
298
+
}
299
+
300
+
const commandPath = `../commands/${commandDir}/${name}${ext}`;
301
+
logger.debug(`[Command Executor] Trying to import command from: ${commandPath}`);
302
+
commandModule = await import(commandPath).catch((e) => {
303
+
logger.error(`[Command Executor] Error importing command '${name}':`, e);
304
+
throw e;
305
+
});
306
+
307
+
if (name === 'cat' || name === 'dog') {
308
+
try {
309
+
const imageData =
310
+
name === 'cat'
311
+
? await commandModule.fetchCatImage()
312
+
: await commandModule.fetchDogImage();
313
+
314
+
return JSON.stringify({
315
+
success: true,
316
+
type: name,
317
+
title: imageData.title || `Random ${name === 'cat' ? 'Cat' : 'Dog'}`,
318
+
url: imageData.url,
319
+
subreddit: imageData.subreddit,
320
+
source: name === 'cat' ? 'pur.cat' : 'erm.dog',
321
+
});
322
+
} catch (error) {
323
+
const errorMessage =
324
+
error instanceof Error ? error.message : `Unknown error fetching ${name} image`;
325
+
logger.error(`[${name}] Error: ${errorMessage}`, { error });
326
+
return JSON.stringify({
327
+
success: false,
328
+
error: `Failed to fetch ${name} image: ${errorMessage}`,
329
+
});
330
+
}
331
+
}
332
+
} catch (error) {
333
+
logger.error(`[Command Executor] Error importing command '${name}':`, error);
334
+
throw new Error(`Command '${name}' not found`);
335
+
}
336
+
337
+
const command = commandModule.default as SlashCommandProps;
338
+
339
+
if (!command) {
340
+
throw new Error(`Command '${name}' not found`);
341
+
}
342
+
343
+
let capturedResponse: unknown = null;
344
+
345
+
const mockInteraction = {
346
+
...interaction,
347
+
options: {
348
+
getString: (param: string) => {
349
+
if (typeof args === 'object' && args !== null) {
350
+
const argsObj = args as Record<string, unknown>;
351
+
return (argsObj[param] as string) || '';
352
+
}
353
+
return '';
354
+
},
355
+
getNumber: (param: string) => {
356
+
if (typeof args === 'object' && args !== null) {
357
+
const argsObj = args as Record<string, unknown>;
358
+
const value = argsObj[param];
359
+
return value !== null && value !== undefined ? Number(value) : null;
360
+
}
361
+
return null;
362
+
},
363
+
getBoolean: (param: string) => {
364
+
if (typeof args === 'object' && args !== null) {
365
+
const argsObj = args as Record<string, unknown>;
366
+
const value = argsObj[param];
367
+
return typeof value === 'boolean' ? value : null;
368
+
}
369
+
return null;
370
+
},
371
+
},
372
+
deferReply: async () => {
373
+
return Promise.resolve();
374
+
},
375
+
reply: async (options: InteractionReplyOptions | MessagePayload) => {
376
+
if ('embeds' in options && options.embeds && options.embeds.length > 0) {
377
+
const processedEmbeds = options.embeds.map((e) => toPlainEmbedObject(e));
378
+
capturedResponse = { ...(options as Record<string, unknown>), embeds: processedEmbeds };
379
+
} else {
380
+
capturedResponse = options;
381
+
}
382
+
if ('embeds' in options && options.embeds && options.embeds.length > 0) {
383
+
return JSON.stringify({
384
+
success: true,
385
+
embeds:
386
+
'embeds' in (capturedResponse as Record<string, unknown>)
387
+
? (capturedResponse as { embeds?: unknown[] }).embeds
388
+
: options.embeds,
389
+
});
390
+
}
391
+
return JSON.stringify({
392
+
success: true,
393
+
content: 'content' in options ? options.content : undefined,
394
+
});
395
+
},
396
+
editReply: async (options: InteractionReplyOptions | MessagePayload) => {
397
+
if ('embeds' in options && options.embeds && options.embeds.length > 0) {
398
+
const processedEmbeds = options.embeds.map((e) => toPlainEmbedObject(e));
399
+
capturedResponse = { ...(options as Record<string, unknown>), embeds: processedEmbeds };
400
+
} else {
401
+
capturedResponse = options;
402
+
}
403
+
logger.debug(`[Command Executor] editReply called with options:`, {
404
+
hasEmbeds: 'embeds' in options && options.embeds && options.embeds.length > 0,
405
+
hasContent: 'content' in options,
406
+
options: options,
407
+
});
408
+
409
+
if ('embeds' in options && options.embeds && options.embeds.length > 0) {
410
+
logger.debug(`[Command Executor] Raw embeds before processing:`, options.embeds);
411
+
412
+
testEmbedBuilderStructure();
413
+
414
+
const processedEmbeds = options.embeds.map((embed) => {
415
+
const embedObj = embed as EmbedInput;
416
+
logger.debug(`[Command Executor] Processing embed:`, {
417
+
hasData: !!(embedObj && typeof embedObj === 'object' && 'data' in embedObj),
418
+
embedKeys: embedObj ? Object.keys(embedObj) : [],
419
+
embedType: (embed as { constructor?: { name?: string } })?.constructor?.name,
420
+
embedPrototype: Object.getPrototypeOf(embedObj as object)?.constructor?.name,
421
+
});
422
+
const plain = toPlainEmbedObject(embedObj);
423
+
return plain;
424
+
});
425
+
426
+
logger.debug(`[Command Executor] Processed embeds:`, processedEmbeds);
427
+
428
+
if (processedEmbeds.length > 0) {
429
+
const firstEmbed = processedEmbeds[0];
430
+
const firstEmbedObj = firstEmbed as Record<string, unknown>;
431
+
logger.debug(`[Command Executor] First processed embed details:`, {
432
+
title: (firstEmbedObj as { title?: unknown })?.title,
433
+
description: (firstEmbedObj as { description?: unknown })?.description,
434
+
fields: (firstEmbedObj as { fields?: unknown })?.fields,
435
+
fieldsLength: (firstEmbedObj as { fields?: Array<unknown> })?.fields?.length,
436
+
allKeys: firstEmbedObj ? Object.keys(firstEmbedObj) : [],
437
+
});
438
+
439
+
logger.debug(
440
+
`[Command Executor] Full embed structure:`,
441
+
JSON.stringify(firstEmbedObj, null, 2),
442
+
);
443
+
}
444
+
445
+
const response = {
446
+
success: true,
447
+
embeds: processedEmbeds,
448
+
};
449
+
logger.debug(`[Command Executor] Returning embed response:`, response);
450
+
return JSON.stringify(response);
451
+
}
452
+
const response = {
453
+
success: true,
454
+
content: 'content' in options ? options.content : undefined,
455
+
};
456
+
logger.debug(`[Command Executor] Returning content response:`, response);
457
+
return JSON.stringify(response);
458
+
},
459
+
followUp: async (options: InteractionReplyOptions | MessagePayload) => {
460
+
capturedResponse = options;
461
+
return JSON.stringify({
462
+
success: true,
463
+
content: 'content' in options ? options.content : undefined,
464
+
});
465
+
},
466
+
} as unknown as ChatInputCommandInteraction;
467
+
468
+
try {
469
+
const result = await command.execute(client, mockInteraction);
470
+
471
+
const responseToProcess = capturedResponse || result;
472
+
473
+
logger.debug(`[Command Executor] Processing response for ${name}:`, {
474
+
hasCapturedResponse: !!capturedResponse,
475
+
hasResult: result !== undefined,
476
+
responseType: typeof responseToProcess,
477
+
});
478
+
479
+
if (!responseToProcess) {
480
+
return JSON.stringify({
481
+
success: true,
482
+
message: 'Command executed successfully',
483
+
});
484
+
}
485
+
486
+
if (typeof responseToProcess === 'string') {
487
+
return JSON.stringify({
488
+
success: true,
489
+
content: responseToProcess,
490
+
});
491
+
}
492
+
493
+
if (typeof responseToProcess === 'object') {
494
+
const response = responseToProcess as Record<string, unknown>;
495
+
496
+
if (Array.isArray(response.embeds) && response.embeds.length > 0) {
497
+
const embeds = response.embeds as Array<{
498
+
title?: string;
499
+
description?: string;
500
+
fields?: Array<{ name: string; value: string; inline?: boolean }>;
501
+
color?: number;
502
+
timestamp?: string | number | Date;
503
+
footer?: { text: string; icon_url?: string };
504
+
}>;
505
+
506
+
logger.debug(`[Command Executor] Processing embeds for ${name}:`, {
507
+
embedCount: embeds.length,
508
+
firstEmbed: embeds[0],
509
+
embedFields: embeds[0]?.fields,
510
+
embedTitle: embeds[0]?.title,
511
+
});
512
+
513
+
if (name === 'weather') {
514
+
const embed = embeds[0];
515
+
logger.debug(`[Command Executor] Weather embed details:`, {
516
+
hasEmbed: !!embed,
517
+
hasFields: !!(embed && embed.fields),
518
+
fieldsCount: embed?.fields?.length || 0,
519
+
embedData: embed,
520
+
});
521
+
522
+
if (embed && embed.fields && embed.fields.length > 0) {
523
+
const f = embed.fields;
524
+
const title = embed.title || '';
525
+
let locationFromTitle = '';
526
+
try {
527
+
const m = title.match(/([A-Za-zร-รร-รถรธ-รฟ' .-]+,\s*[A-Z]{2})/u);
528
+
if (m && m[1]) {
529
+
locationFromTitle = m[1].trim();
530
+
}
531
+
} catch (_err) {
532
+
/* ignore */
533
+
}
534
+
let locationArg = '';
535
+
if (args && typeof args === 'object') {
536
+
const a = args as Record<string, unknown>;
537
+
locationArg = String(a.location || a.query || a.search || '').trim();
538
+
}
539
+
const weatherResponse = {
540
+
success: true,
541
+
type: 'weather',
542
+
location: locationFromTitle || locationArg || 'Unknown location',
543
+
temperature: f[0]?.value || 'N/A',
544
+
feels_like: f[1]?.value || 'N/A',
545
+
conditions: f[2]?.value || 'N/A',
546
+
humidity: f[3]?.value || 'N/A',
547
+
wind_speed: f[4]?.value || 'N/A',
548
+
pressure: f[5]?.value || 'N/A',
549
+
handled: true,
550
+
};
551
+
logger.debug(`[Command Executor] Weather response:`, weatherResponse);
552
+
return JSON.stringify(weatherResponse);
553
+
}
554
+
logger.debug(`[Command Executor] Weather embed has no fields, using fallback`);
555
+
logger.debug(`[Command Executor] Weather embed fallback data:`, {
556
+
title: embed?.title,
557
+
description: embed?.description,
558
+
fields: embed?.fields,
559
+
allProperties: embed ? Object.keys(embed) : [],
560
+
});
561
+
562
+
const fallbackResponse = {
563
+
success: true,
564
+
type: 'weather',
565
+
location:
566
+
embed?.title ||
567
+
(args && typeof args === 'object'
568
+
? String(
569
+
(args as Record<string, unknown>).location ||
570
+
(args as Record<string, unknown>).query ||
571
+
(args as Record<string, unknown>).search ||
572
+
'',
573
+
)
574
+
: '') ||
575
+
'Unknown location',
576
+
description: embed?.description || 'Weather data unavailable',
577
+
rawEmbed: embed,
578
+
handled: true,
579
+
};
580
+
581
+
return JSON.stringify(fallbackResponse);
582
+
}
583
+
584
+
if (name === 'joke') {
585
+
const embed = embeds[0];
586
+
return JSON.stringify({
587
+
success: true,
588
+
type: 'joke',
589
+
title: embed.title || 'Random Joke',
590
+
setup: embed.description || 'No joke available',
591
+
handled: true,
592
+
});
593
+
}
594
+
595
+
return JSON.stringify({
596
+
success: true,
597
+
embeds: embeds.map((embed) => ({
598
+
title: embed.title,
599
+
description: embed.description,
600
+
fields:
601
+
embed.fields?.map((f) => ({
602
+
name: f.name,
603
+
value: f.value,
604
+
inline: f.inline,
605
+
})) || [],
606
+
color: embed.color,
607
+
timestamp: embed.timestamp,
608
+
footer: embed.footer,
609
+
})),
610
+
});
611
+
}
612
+
613
+
if (Array.isArray(response.components) && response.components.length > 0) {
614
+
if (name === '8ball') {
615
+
const components = response.components as Array<{
616
+
components?: Array<{
617
+
data?: {
618
+
components?: Array<{
619
+
data?: {
620
+
content?: string;
621
+
};
622
+
}>;
623
+
};
624
+
}>;
625
+
}>;
626
+
627
+
let question = '';
628
+
let answer = '';
629
+
630
+
for (const component of components) {
631
+
if (component.components) {
632
+
for (const subComponent of component.components) {
633
+
if (subComponent.data?.components) {
634
+
for (const textComponent of subComponent.data.components) {
635
+
const content = textComponent.data?.content || '';
636
+
if (content.includes('**Question**') || content.includes('**Pregunta**')) {
637
+
question = content
638
+
.replace(/.*?\*\*(.*?)\*\*\s*>\s*(.*?)\n\n.*/s, '$2')
639
+
.trim();
640
+
} else if (
641
+
content.includes('**Answer**') ||
642
+
content.includes('**Respuesta**') ||
643
+
content.includes('โจ')
644
+
) {
645
+
answer = content.replace(/.*?โจ\s*(.*?)$/s, '$1').trim();
646
+
}
647
+
}
648
+
}
649
+
}
650
+
}
651
+
}
652
+
653
+
return JSON.stringify({
654
+
success: true,
655
+
type: '8ball',
656
+
question: question || 'Unknown question',
657
+
answer: answer || 'Unknown answer',
658
+
handled: true,
659
+
});
660
+
}
661
+
}
662
+
663
+
if (typeof response.content === 'string') {
664
+
return JSON.stringify({
665
+
success: true,
666
+
content: response.content,
667
+
});
668
+
}
669
+
}
670
+
671
+
return JSON.stringify({
672
+
success: true,
673
+
message: 'Command executed successfully',
674
+
});
675
+
} catch (error) {
676
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
677
+
logger.error(`Error executing command '${name}':`, errorMessage);
678
+
return JSON.stringify({
679
+
success: false,
680
+
error: errorMessage,
681
+
status: 500,
682
+
});
683
+
}
684
+
} catch (error) {
685
+
logger.error(`Error executing tool call '${name}':`, error);
686
+
return JSON.stringify({
687
+
success: false,
688
+
error: `Error executing tool call '${name}': ${error}`,
689
+
status: 500,
690
+
});
691
+
}
692
+
}
+1
-10
src/utils/encrypt.ts
+1
-10
src/utils/encrypt.ts
···
131
131
}
132
132
}
133
133
134
-
function canDecrypt(encrypted: string): boolean {
135
-
try {
136
-
decrypt(encrypted);
137
-
return true;
138
-
} catch {
139
-
return false;
140
-
}
141
-
}
142
-
143
134
function isValidEncryptedFormat(encrypted: string): boolean {
144
135
if (!encrypted || typeof encrypted !== 'string') {
145
136
return false;
···
162
153
}
163
154
}
164
155
165
-
export { encrypt, decrypt, canDecrypt, isValidEncryptedFormat, EncryptionError };
156
+
export { encrypt, decrypt, isValidEncryptedFormat, EncryptionError };
-82
src/utils/encryption.ts
-82
src/utils/encryption.ts
···
1
-
import crypto from 'crypto';
2
-
import { API_KEY_ENCRYPTION_SECRET } from '../config';
3
-
4
-
const ENCRYPTION_KEY = API_KEY_ENCRYPTION_SECRET;
5
-
const ALGORITHM = 'aes-256-gcm';
6
-
7
-
const getEncryptionKey = (): Buffer => {
8
-
if (ENCRYPTION_KEY.length !== 32) {
9
-
throw new Error('ENCRYPTION_KEY must be exactly 32 characters long');
10
-
}
11
-
return Buffer.from(ENCRYPTION_KEY, 'utf8');
12
-
};
13
-
14
-
/**
15
-
* Encrypts a string using AES-256-GCM
16
-
* @param text The text to encrypt
17
-
* @returns Base64 encoded encrypted data with IV and auth tag
18
-
*/
19
-
export const encryptApiKey = (text: string): string => {
20
-
try {
21
-
const key = getEncryptionKey();
22
-
const iv = crypto.randomBytes(16);
23
-
24
-
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
25
-
cipher.setAAD(Buffer.from('aethel-api-key', 'utf8'));
26
-
27
-
let encrypted = cipher.update(text, 'utf8', 'hex');
28
-
encrypted += cipher.final('hex');
29
-
30
-
const authTag = cipher.getAuthTag();
31
-
32
-
const combined = Buffer.concat([iv, authTag, Buffer.from(encrypted, 'hex')]);
33
-
34
-
return combined.toString('base64');
35
-
} catch {
36
-
throw new Error('Failed to encrypt API key');
37
-
}
38
-
};
39
-
40
-
/**
41
-
* Decrypts a string that was encrypted with encryptApiKey
42
-
* @param encryptedData Base64 encoded encrypted data
43
-
* @returns The decrypted text
44
-
*/
45
-
export const decryptApiKey = (encryptedData: string): string => {
46
-
try {
47
-
const key = getEncryptionKey();
48
-
const combined = Buffer.from(encryptedData, 'base64');
49
-
50
-
const extractedIv = combined.subarray(0, 16);
51
-
const authTag = combined.subarray(16, 32);
52
-
const encrypted = combined.subarray(32);
53
-
54
-
const decipher = crypto.createDecipheriv(ALGORITHM, key, extractedIv);
55
-
decipher.setAAD(Buffer.from('aethel-api-key', 'utf8'));
56
-
decipher.setAuthTag(authTag);
57
-
58
-
let decrypted = decipher.update(encrypted, undefined, 'utf8');
59
-
decrypted += decipher.final('utf8');
60
-
61
-
return decrypted;
62
-
} catch {
63
-
throw new Error('Failed to decrypt API key');
64
-
}
65
-
};
66
-
67
-
/**
68
-
* Generates a secure random encryption key
69
-
* @returns A 32-character random string suitable for use as ENCRYPTION_KEY
70
-
*/
71
-
export const generateEncryptionKey = (): string => {
72
-
return crypto.randomBytes(32).toString('base64').substring(0, 32);
73
-
};
74
-
75
-
/**
76
-
* Validates that an encryption key is properly formatted
77
-
* @param key The key to validate
78
-
* @returns True if the key is valid
79
-
*/
80
-
export const validateEncryptionKey = (key: string): boolean => {
81
-
return typeof key === 'string' && key.length === 32;
82
-
};
+1
-1
src/utils/getGitCommitHash.ts
+1
-1
src/utils/getGitCommitHash.ts
+11
-4
src/utils/logger.ts
+11
-4
src/utils/logger.ts
···
58
58
new winston.transports.Console({
59
59
format: winston.format.combine(
60
60
winston.format.colorize(),
61
-
winston.format.printf(({ timestamp, level, message, service, ...meta }) => {
62
-
const metaStr = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : '';
63
-
return `${timestamp} [${service}] ${level}: ${message} ${metaStr}`;
64
-
}),
61
+
winston.format.printf(
62
+
({ timestamp, level, message, service, environment, version, ...metadata }) => {
63
+
let logMessage = `${timestamp} [${service}] [${environment}] [${version}] ${level}: ${message}`;
64
+
65
+
if (Object.keys(metadata).length > 0) {
66
+
logMessage += ` ${JSON.stringify(metadata)}`;
67
+
}
68
+
69
+
return logMessage;
70
+
},
71
+
),
65
72
),
66
73
}),
67
74
],
+395
src/utils/messageToolExecutor.ts
+395
src/utils/messageToolExecutor.ts
···
1
+
import { Message } from 'discord.js';
2
+
import BotClient from '@/services/Client';
3
+
import logger from '@/utils/logger';
4
+
import fetch from 'node-fetch';
5
+
6
+
interface WikipediaPage {
7
+
pageid?: number;
8
+
ns?: number;
9
+
title: string;
10
+
extract?: string;
11
+
thumbnail?: {
12
+
source: string;
13
+
width: number;
14
+
height: number;
15
+
};
16
+
pageimage?: string;
17
+
missing?: boolean;
18
+
}
19
+
20
+
interface _WikipediaResponse {
21
+
query: {
22
+
pages: Record<string, WikipediaPage>;
23
+
};
24
+
}
25
+
26
+
interface _WeatherResponse {
27
+
main: {
28
+
temp: number;
29
+
feels_like: number;
30
+
humidity: number;
31
+
};
32
+
weather: Array<{
33
+
description: string;
34
+
icon: string;
35
+
}>;
36
+
wind: {
37
+
speed: number;
38
+
};
39
+
name: string;
40
+
sys: {
41
+
country: string;
42
+
};
43
+
}
44
+
45
+
export interface MessageToolCall {
46
+
name: string;
47
+
args: Record<string, unknown>;
48
+
}
49
+
50
+
export interface ToolResult {
51
+
content: Array<{
52
+
type: string;
53
+
text?: string;
54
+
image_url?: {
55
+
url: string;
56
+
detail?: 'low' | 'high' | 'auto';
57
+
};
58
+
}>;
59
+
metadata: {
60
+
type: string;
61
+
url?: string;
62
+
title?: string;
63
+
subreddit?: string;
64
+
source?: string;
65
+
isSystem?: boolean;
66
+
[key: string]: unknown;
67
+
};
68
+
}
69
+
70
+
function formatToolResponse(
71
+
content: string,
72
+
metadata: Record<string, unknown> = {},
73
+
showSystemMessage = true,
74
+
): ToolResult {
75
+
if (showSystemMessage) {
76
+
return {
77
+
content: [
78
+
{
79
+
type: 'text',
80
+
text: `[SYSTEM] ${content}`,
81
+
},
82
+
],
83
+
metadata: {
84
+
...metadata,
85
+
type: 'tool_response',
86
+
isSystem: true,
87
+
},
88
+
};
89
+
}
90
+
91
+
return {
92
+
content: [],
93
+
metadata: {
94
+
...metadata,
95
+
type: 'tool_response',
96
+
isSystem: false,
97
+
},
98
+
};
99
+
}
100
+
101
+
async function catTool(): Promise<ToolResult> {
102
+
try {
103
+
const res = await fetch('https://api.pur.cat/random-cat');
104
+
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
105
+
106
+
const data = (await res.json()) as { url: string; title?: string; subreddit?: string };
107
+
return formatToolResponse(`Here's a cute cat for you! ๐ฑ\n\nImage URL: ${data.url}`, {
108
+
type: 'cat',
109
+
url: data.url,
110
+
title: data.title,
111
+
subreddit: data.subreddit,
112
+
source: 'pur.cat',
113
+
});
114
+
} catch (error) {
115
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
116
+
logger.error('Error in cat tool:', error);
117
+
throw new Error(`Failed to fetch cat image: ${errorMessage}`);
118
+
}
119
+
}
120
+
121
+
async function dogTool(): Promise<ToolResult> {
122
+
try {
123
+
const headers = {
124
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
125
+
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
126
+
};
127
+
128
+
const res = await fetch('https://api.erm.dog/random-dog', { headers });
129
+
if (!res.ok) throw new Error(`API request failed with status ${res.status}`);
130
+
131
+
const data = (await res.json()) as { url: string; title?: string; subreddit?: string };
132
+
133
+
if (!data || !data.url) {
134
+
throw new Error('Invalid response format from dog API');
135
+
}
136
+
137
+
return formatToolResponse(`Here's a cute dog for you! ๐ถ\n\nImage URL: ${data.url}`, {
138
+
type: 'dog',
139
+
url: data.url,
140
+
title: data.title || 'Random Dog',
141
+
subreddit: data.subreddit || 'dogpictures',
142
+
source: 'erm.dog',
143
+
});
144
+
} catch (error) {
145
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
146
+
logger.error('Error in dog tool:', error);
147
+
throw new Error(`Failed to fetch dog image: ${errorMessage}`);
148
+
}
149
+
}
150
+
151
+
interface WikipediaSummary {
152
+
title: string;
153
+
extract: string;
154
+
content_urls?: {
155
+
desktop?: {
156
+
page: string;
157
+
};
158
+
};
159
+
}
160
+
161
+
async function wikiTool(args: Record<string, unknown>): Promise<ToolResult> {
162
+
const query = args.query as string;
163
+
if (!query) {
164
+
throw new Error('Query is required for wiki tool');
165
+
}
166
+
167
+
try {
168
+
const url = `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(query)}`;
169
+
const res = await fetch(url);
170
+
171
+
if (!res.ok) {
172
+
throw new Error(`Wikipedia API error: ${res.status} ${res.statusText}`);
173
+
}
174
+
175
+
const data = (await res.json()) as WikipediaSummary;
176
+
const title = data.title || query;
177
+
const extract = data.extract || 'No summary available.';
178
+
const pageUrl =
179
+
data.content_urls?.desktop?.page ||
180
+
`https://en.wikipedia.org/wiki/${encodeURIComponent(query)}`;
181
+
182
+
return formatToolResponse(`${title}\n\n${extract}\n\nSource: ${pageUrl}`, {
183
+
type: 'wiki',
184
+
title,
185
+
extract,
186
+
url: pageUrl,
187
+
});
188
+
} catch (error) {
189
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
190
+
logger.error('Error in wiki tool:', error);
191
+
throw new Error(`Failed to search Wikipedia: ${errorMessage}`);
192
+
}
193
+
}
194
+
195
+
interface WeatherData {
196
+
main: {
197
+
temp: number;
198
+
feels_like: number;
199
+
humidity: number;
200
+
pressure: number;
201
+
};
202
+
weather: Array<{
203
+
description: string;
204
+
}>;
205
+
wind: {
206
+
speed: number;
207
+
};
208
+
name: string;
209
+
}
210
+
211
+
async function weatherTool(args: Record<string, unknown>): Promise<ToolResult> {
212
+
const location = args.location as string;
213
+
if (!location) {
214
+
throw new Error('Location is required for weather tool');
215
+
}
216
+
217
+
const apiKey = process.env.OPENWEATHER_API_KEY;
218
+
if (!apiKey) {
219
+
throw new Error('OpenWeather API key not configured');
220
+
}
221
+
222
+
try {
223
+
const res = await fetch(
224
+
`https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(location)}&appid=${apiKey}&units=imperial`,
225
+
);
226
+
227
+
if (!res.ok) {
228
+
throw new Error(`Weather API error: ${res.status} ${res.statusText}`);
229
+
}
230
+
231
+
const data = (await res.json()) as WeatherData;
232
+
const temp = Math.round(data.main.temp);
233
+
const feels = Math.round(data.main.feels_like);
234
+
const conditions = data.weather[0]?.description || 'Unknown';
235
+
const humidity = data.main.humidity;
236
+
const wind = Math.round(data.wind.speed);
237
+
const pressure = data.main.pressure;
238
+
const city = data.name || location;
239
+
240
+
return formatToolResponse(
241
+
`Weather for ${city}: ${temp}ยฐF (feels ${feels}ยฐF), ${conditions}. ` +
242
+
`Humidity ${humidity}%, Wind ${wind} mph, Pressure ${pressure} hPa.`,
243
+
{
244
+
type: 'weather',
245
+
location: city,
246
+
temperature: temp,
247
+
feels_like: feels,
248
+
conditions,
249
+
humidity,
250
+
wind_speed: wind,
251
+
pressure,
252
+
},
253
+
);
254
+
} catch (error) {
255
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
256
+
logger.error('Error in weather tool:', error);
257
+
throw new Error(`Failed to get weather: ${errorMessage}`);
258
+
}
259
+
}
260
+
261
+
function resolveEmoji(input: string): string {
262
+
const shortcode = input.match(/^:([a-z0-9_+-]+):$/i)?.[1];
263
+
if (shortcode) {
264
+
const map: Record<string, string> = {
265
+
thumbsup: '๐',
266
+
thumbsdown: '๐',
267
+
'+1': '๐',
268
+
'-1': '๐',
269
+
thumbs_up: '๐',
270
+
thumbs_down: '๐',
271
+
heart: 'โค๏ธ',
272
+
smile: '๐',
273
+
grin: '๐',
274
+
joy: '๐',
275
+
cry: '๐ข',
276
+
sob: '๐ญ',
277
+
clap: '๐',
278
+
fire: '๐ฅ',
279
+
star: 'โญ',
280
+
eyes: '๐',
281
+
tada: '๐',
282
+
};
283
+
const key = shortcode.toLowerCase();
284
+
return map[key] || input;
285
+
}
286
+
return input;
287
+
}
288
+
289
+
async function reactionTool(
290
+
args: Record<string, unknown>,
291
+
message: Message,
292
+
client: BotClient,
293
+
opts?: { originalMessage?: Message; botMessage?: Message },
294
+
): Promise<ToolResult> {
295
+
const emoji = (args.emoji || args.query || '') as string;
296
+
const target = ((args.target as string) || 'user').toLowerCase();
297
+
const targetMessage = target === 'bot' && opts?.botMessage ? opts.botMessage : message;
298
+
299
+
if (!emoji) {
300
+
throw new Error('Emoji is required for reaction tool');
301
+
}
302
+
303
+
const resolvedEmoji = resolveEmoji(emoji);
304
+
305
+
try {
306
+
await targetMessage.react(resolvedEmoji);
307
+
308
+
return {
309
+
content: [],
310
+
metadata: {
311
+
type: 'reaction',
312
+
emoji: resolvedEmoji,
313
+
target,
314
+
success: true,
315
+
handled: true,
316
+
isSystem: false,
317
+
},
318
+
};
319
+
} catch (error) {
320
+
logger.error('Failed to add reaction:', { emoji: resolvedEmoji, error });
321
+
throw new Error(
322
+
`Failed to add reaction: ${error instanceof Error ? error.message : 'Unknown error'}`,
323
+
);
324
+
}
325
+
}
326
+
327
+
type ToolFunction = (
328
+
args: Record<string, unknown>,
329
+
message: Message,
330
+
client: BotClient,
331
+
opts?: { originalMessage?: Message; botMessage?: Message },
332
+
) => Promise<ToolResult>;
333
+
334
+
const TOOLS: Record<string, ToolFunction> = {
335
+
cat: catTool,
336
+
dog: dogTool,
337
+
wiki: wikiTool,
338
+
weather: weatherTool,
339
+
reaction: (args, message, client, opts) => reactionTool(args, message, client, opts),
340
+
newmessage: () =>
341
+
Promise.resolve({
342
+
content: [],
343
+
metadata: {
344
+
type: 'newmessage',
345
+
success: true,
346
+
handled: true,
347
+
isSystem: false,
348
+
},
349
+
}),
350
+
};
351
+
352
+
export async function executeMessageToolCall(
353
+
toolCall: MessageToolCall,
354
+
message: Message,
355
+
_client: BotClient,
356
+
_opts?: { originalMessage?: Message; botMessage?: Message },
357
+
): Promise<{
358
+
success: boolean;
359
+
type: string;
360
+
handled: boolean;
361
+
error?: string;
362
+
result?: ToolResult;
363
+
}> {
364
+
const name = (toolCall.name || '').toLowerCase();
365
+
366
+
try {
367
+
const tool = TOOLS[name];
368
+
if (!tool) {
369
+
return {
370
+
success: false,
371
+
type: 'error',
372
+
handled: false,
373
+
error: `Tool '${name}' not found`,
374
+
};
375
+
}
376
+
377
+
const result = await tool(toolCall.args || {}, message, _client, _opts);
378
+
379
+
return {
380
+
success: true,
381
+
type: result.metadata.type || name,
382
+
handled: true,
383
+
result,
384
+
};
385
+
} catch (error) {
386
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
387
+
logger.error(`Error in executeMessageToolCall for ${name}:`, error);
388
+
return {
389
+
success: false,
390
+
type: 'error',
391
+
handled: true,
392
+
error: errorMessage,
393
+
};
394
+
}
395
+
}
-9
src/utils/misc.ts
-9
src/utils/misc.ts
···
5
5
return array[Math.floor(Math.random() * array.length)];
6
6
}
7
7
8
-
export function iso2ToFlagEmoji(iso2: string): string {
9
-
if (!iso2 || iso2.length !== 2) return '';
10
-
const upper = iso2.toUpperCase();
11
-
if (!/^[A-Z]{2}$/.test(upper)) return '';
12
-
const codePoints = upper.split('').map((char) => 0x1f1e6 + char.charCodeAt(0) - 65);
13
-
if (codePoints.some((cp) => cp < 0x1f1e6 || cp > 0x1f1ff)) return '';
14
-
return String.fromCodePoint(...codePoints);
15
-
}
16
-
17
8
export function iso2ToDiscordFlag(iso2: string): string {
18
9
if (!iso2 || iso2.length !== 2) return '';
19
10
return `:flag_${iso2.toLowerCase()}:`;
+31
src/utils/rateLimiter.ts
+31
src/utils/rateLimiter.ts
···
1
+
type RateLimitedTask<T> = () => Promise<T> | T;
2
+
3
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
4
+
5
+
export function createRateLimiter(limitPerSecond: number) {
6
+
const minDelay = Math.ceil(1000 / Math.max(1, limitPerSecond));
7
+
let lastRun = 0;
8
+
let chain: Promise<void> = Promise.resolve();
9
+
10
+
const schedule = async <T>(task: RateLimitedTask<T>): Promise<T> => {
11
+
const execute = chain.then(async () => {
12
+
const elapsed = Date.now() - lastRun;
13
+
const waitTime = lastRun === 0 ? 0 : Math.max(0, minDelay - elapsed);
14
+
if (waitTime > 0) {
15
+
await sleep(waitTime);
16
+
}
17
+
18
+
const result = await task();
19
+
lastRun = Date.now();
20
+
return result;
21
+
});
22
+
23
+
chain = execute.then(
24
+
() => undefined,
25
+
() => undefined,
26
+
);
27
+
return execute;
28
+
};
29
+
30
+
return { schedule };
31
+
}
+86
-5
src/utils/sendDeploymentNotification.ts
+86
-5
src/utils/sendDeploymentNotification.ts
···
4
4
5
5
function getGitInfo() {
6
6
try {
7
-
const commitHash =
8
-
process.env.SOURCE_COMMIT || execSync('git rev-parse HEAD').toString().trim();
9
-
const commitMessage = execSync('git log -1 --pretty=%B').toString().trim();
7
+
try {
8
+
execSync('git rev-parse --is-inside-work-tree');
9
+
10
+
try {
11
+
execSync('git remote get-url origin');
12
+
} catch {
13
+
execSync('git remote add origin https://github.com/aethel/aethel-labs');
14
+
}
15
+
16
+
try {
17
+
execSync('git fetch --depth=100 origin');
18
+
} catch (e) {
19
+
logger.debug('git fetch failed or unnecessary; continuing', {
20
+
error: (e as Error).message,
21
+
});
22
+
}
23
+
} catch (e) {
24
+
logger.debug('Not a git repository; initializing temporary repo for metadata', {
25
+
error: (e as Error).message,
26
+
});
27
+
try {
28
+
execSync('git init');
29
+
try {
30
+
execSync('git remote add origin https://github.com/aethel/aethel-labs');
31
+
} catch (e) {
32
+
logger.debug('origin remote already exists or cannot be added', {
33
+
error: (e as Error).message,
34
+
});
35
+
}
36
+
const sourceCommit = process.env.SOURCE_COMMIT;
37
+
if (sourceCommit) {
38
+
try {
39
+
execSync(`git fetch --depth=1 origin ${sourceCommit}`);
40
+
} catch (err) {
41
+
logger.debug('Failed to fetch SOURCE_COMMIT from origin', {
42
+
error: (err as Error).message,
43
+
});
44
+
}
45
+
} else {
46
+
try {
47
+
const remoteHead = execSync('git ls-remote origin HEAD').toString().split('\t')[0];
48
+
if (remoteHead) {
49
+
execSync(`git fetch --depth=1 origin ${remoteHead}`);
50
+
process.env.SOURCE_COMMIT = remoteHead;
51
+
}
52
+
} catch (err) {
53
+
logger.debug('Failed to resolve remote HEAD', { error: (err as Error).message });
54
+
}
55
+
}
56
+
} catch (err) {
57
+
logger.debug('Failed to bootstrap temporary git repo', { error: (err as Error).message });
58
+
}
59
+
}
60
+
61
+
let commitHash: string | null = null;
62
+
try {
63
+
commitHash = process.env.SOURCE_COMMIT || execSync('git rev-parse HEAD').toString().trim();
64
+
} catch {
65
+
try {
66
+
const remoteHead = execSync('git ls-remote origin HEAD').toString().split('\t')[0];
67
+
commitHash = remoteHead || null;
68
+
} catch (e) {
69
+
logger.debug('Failed to resolve remote HEAD for commit hash', {
70
+
error: (e as Error).message,
71
+
});
72
+
}
73
+
}
74
+
75
+
const shortHash = commitHash ? commitHash.substring(0, 7) : 'unknown';
76
+
let commitMessage = 'No commit message';
77
+
try {
78
+
commitMessage = commitHash
79
+
? execSync(`git log -1 --pretty=%B ${commitHash}`).toString().trim()
80
+
: commitMessage;
81
+
} catch (e) {
82
+
logger.debug('Failed to resolve commit message', { error: (e as Error).message });
83
+
}
10
84
const branch =
11
85
process.env.GIT_BRANCH ||
12
86
process.env.VERCEL_GIT_COMMIT_REF ||
13
87
process.env.COOLIFY_BRANCH ||
14
-
execSync('git rev-parse --abbrev-ref HEAD').toString().trim();
88
+
(() => {
89
+
try {
90
+
return execSync('git rev-parse --abbrev-ref HEAD').toString().trim();
91
+
} catch (e) {
92
+
logger.debug('Failed to resolve branch', { error: (e as Error).message });
93
+
return 'unknown';
94
+
}
95
+
})();
15
96
16
97
return {
17
-
commitHash: commitHash.substring(0, 7),
98
+
commitHash: shortHash,
18
99
commitMessage,
19
100
branch,
20
101
};
+199
src/utils/stockChart.ts
+199
src/utils/stockChart.ts
···
1
+
import { createCanvas } from 'canvas';
2
+
import { StockAggregatePoint, StockTimeframe } from '@/services/massive';
3
+
4
+
const WIDTH = 900;
5
+
const HEIGHT = 460;
6
+
const PADDING = {
7
+
top: 24,
8
+
right: 32,
9
+
bottom: 48,
10
+
left: 64,
11
+
};
12
+
const MIN_SPACING = 6;
13
+
const UP_COLOR = '#1AC486';
14
+
const DOWN_COLOR = '#FF6B6B';
15
+
const GRID_COLOR = 'rgba(255,255,255,0.08)';
16
+
const AXIS_COLOR = 'rgba(255,255,255,0.4)';
17
+
const TEXT_COLOR = 'rgba(255,255,255,0.85)';
18
+
const BACKGROUND = '#0f1117';
19
+
20
+
const TIME_LABEL_FORMATTER = new Intl.DateTimeFormat('en-US', {
21
+
hour: 'numeric',
22
+
minute: '2-digit',
23
+
});
24
+
const DATE_LABEL_FORMATTER = new Intl.DateTimeFormat('en-US', {
25
+
month: 'short',
26
+
day: 'numeric',
27
+
});
28
+
const WEEKDAY_LABEL_FORMATTER = new Intl.DateTimeFormat('en-US', {
29
+
weekday: 'short',
30
+
month: 'short',
31
+
day: 'numeric',
32
+
});
33
+
34
+
function formatLabel(timestamp: number, timeframe?: StockTimeframe) {
35
+
const date = new Date(timestamp);
36
+
if (Number.isNaN(date.getTime())) return '';
37
+
if (timeframe === '1d') return TIME_LABEL_FORMATTER.format(date);
38
+
if (timeframe === '5d') return WEEKDAY_LABEL_FORMATTER.format(date);
39
+
return DATE_LABEL_FORMATTER.format(date);
40
+
}
41
+
42
+
export async function renderStockCandles(
43
+
points: StockAggregatePoint[],
44
+
timeframe?: StockTimeframe,
45
+
): Promise<Buffer> {
46
+
if (!points.length) {
47
+
throw new Error('No aggregate data available for chart');
48
+
}
49
+
50
+
const maxCandlesMap: Record<StockTimeframe, number> = {
51
+
'1d': 80,
52
+
'5d': 110,
53
+
'1m': 140,
54
+
'3m': 160,
55
+
'1y': 160,
56
+
};
57
+
const fallbackLimit = 140;
58
+
const limit = maxCandlesMap[timeframe ?? '1m'] ?? fallbackLimit;
59
+
60
+
const sorted = points.slice(-limit).sort((a, b) => a.timestamp - b.timestamp);
61
+
62
+
const chartWidth = WIDTH - PADDING.left - PADDING.right;
63
+
const maxVisible = Math.max(3, Math.floor(chartWidth / MIN_SPACING));
64
+
const candles = sorted.slice(-maxVisible).map((point) => ({
65
+
x: point.timestamp,
66
+
open: point.open,
67
+
high: point.high,
68
+
low: point.low,
69
+
close: point.close,
70
+
}));
71
+
72
+
if (!candles.length) {
73
+
throw new Error('No aggregate data available for chart');
74
+
}
75
+
76
+
const canvas = createCanvas(WIDTH, HEIGHT);
77
+
const ctx = canvas.getContext('2d');
78
+
ctx.antialias = 'subpixel';
79
+
80
+
ctx.fillStyle = BACKGROUND;
81
+
ctx.fillRect(0, 0, WIDTH, HEIGHT);
82
+
83
+
const values = candles.flatMap((candle) => [candle.high, candle.low]);
84
+
const rawMax = Math.max(...values);
85
+
const rawMin = Math.min(...values);
86
+
87
+
const { niceMin, niceMax, tickSpacing } = computeNiceScale(rawMin, rawMax, 5);
88
+
const maxPrice = niceMax;
89
+
const minPrice = niceMin;
90
+
const priceRange = maxPrice - minPrice || 1;
91
+
92
+
const chartHeight = HEIGHT - PADDING.top - PADDING.bottom;
93
+
94
+
const stepX = candles.length > 1 ? chartWidth / (candles.length - 1) : 0;
95
+
const bodyWidth =
96
+
candles.length > 1 ? Math.max(4, Math.min(18, stepX * 0.55)) : Math.min(24, chartWidth * 0.2);
97
+
98
+
const mapY = (value: number) =>
99
+
PADDING.top + chartHeight - ((value - minPrice) / priceRange) * chartHeight;
100
+
101
+
ctx.strokeStyle = GRID_COLOR;
102
+
ctx.lineWidth = 1;
103
+
ctx.font = '12px sans-serif';
104
+
ctx.fillStyle = TEXT_COLOR;
105
+
106
+
const gridLines = Math.max(2, Math.round(priceRange / tickSpacing));
107
+
for (let i = 0; i <= gridLines; i++) {
108
+
const value = maxPrice - tickSpacing * i;
109
+
const clampedValue = Math.max(minPrice, Math.min(maxPrice, value));
110
+
const relative = (maxPrice - clampedValue) / priceRange;
111
+
const y = PADDING.top + chartHeight * relative;
112
+
ctx.beginPath();
113
+
ctx.moveTo(PADDING.left, y);
114
+
ctx.lineTo(WIDTH - PADDING.right, y);
115
+
ctx.stroke();
116
+
117
+
const priceLabel = clampedValue.toFixed(priceRange >= 10 ? 2 : 3);
118
+
ctx.fillText(priceLabel, 16, y + 4);
119
+
}
120
+
121
+
ctx.strokeStyle = AXIS_COLOR;
122
+
ctx.beginPath();
123
+
ctx.moveTo(PADDING.left, PADDING.top);
124
+
ctx.lineTo(PADDING.left, HEIGHT - PADDING.bottom);
125
+
ctx.lineTo(WIDTH - PADDING.right, HEIGHT - PADDING.bottom);
126
+
ctx.stroke();
127
+
128
+
candles.forEach((candle, index) => {
129
+
const x = candles.length === 1 ? PADDING.left + chartWidth / 2 : PADDING.left + stepX * index;
130
+
const openY = mapY(candle.open);
131
+
const closeY = mapY(candle.close);
132
+
const highY = mapY(candle.high);
133
+
const lowY = mapY(candle.low);
134
+
const color = candle.close >= candle.open ? UP_COLOR : DOWN_COLOR;
135
+
136
+
ctx.strokeStyle = color;
137
+
ctx.lineWidth = 1.5;
138
+
ctx.beginPath();
139
+
ctx.moveTo(x, highY);
140
+
ctx.lineTo(x, lowY);
141
+
ctx.stroke();
142
+
143
+
ctx.beginPath();
144
+
const bodyHeight = Math.max(2, Math.abs(closeY - openY));
145
+
const bodyTop = Math.min(openY, closeY);
146
+
ctx.rect(x - bodyWidth / 2, bodyTop, bodyWidth, bodyHeight || 2);
147
+
ctx.fillStyle = color;
148
+
ctx.fill();
149
+
});
150
+
151
+
const labelCount = Math.min(6, candles.length);
152
+
for (let i = 0; i < labelCount; i++) {
153
+
const candleIndex = Math.round((i / Math.max(1, labelCount - 1)) * (candles.length - 1));
154
+
const label = formatLabel(candles[candleIndex].x, timeframe);
155
+
const x =
156
+
candles.length === 1 ? PADDING.left + chartWidth / 2 : PADDING.left + stepX * candleIndex;
157
+
ctx.fillStyle = TEXT_COLOR;
158
+
ctx.fillText(label, x - ctx.measureText(label).width / 2, HEIGHT - PADDING.bottom + 20);
159
+
}
160
+
161
+
return canvas.toBuffer('image/png');
162
+
}
163
+
164
+
function computeNiceScale(min: number, max: number, maxTicks: number) {
165
+
if (!Number.isFinite(min) || !Number.isFinite(max)) {
166
+
return { niceMin: 0, niceMax: 1, tickSpacing: 0.2 };
167
+
}
168
+
if (min === max) {
169
+
const offset = Math.abs(min) * 0.05 || 1;
170
+
min -= offset;
171
+
max += offset;
172
+
}
173
+
174
+
const range = niceNum(max - min, false);
175
+
const tickSpacing = niceNum(range / (maxTicks - 1), true);
176
+
const niceMin = Math.floor(min / tickSpacing) * tickSpacing;
177
+
const niceMax = Math.ceil(max / tickSpacing) * tickSpacing;
178
+
179
+
return { niceMin, niceMax, tickSpacing };
180
+
}
181
+
182
+
function niceNum(range: number, round: boolean) {
183
+
const exponent = Math.floor(Math.log10(range));
184
+
const fraction = range / Math.pow(10, exponent);
185
+
let niceFraction;
186
+
187
+
if (round) {
188
+
if (fraction < 1.5) niceFraction = 1;
189
+
else if (fraction < 3) niceFraction = 2;
190
+
else if (fraction < 7) niceFraction = 5;
191
+
else niceFraction = 10;
192
+
} else {
193
+
if (fraction <= 1) niceFraction = 1;
194
+
else if (fraction <= 2) niceFraction = 2;
195
+
else if (fraction <= 5) niceFraction = 5;
196
+
else niceFraction = 10;
197
+
}
198
+
return niceFraction * Math.pow(10, exponent);
199
+
}
+14
src/utils/topgg.ts
+14
src/utils/topgg.ts
···
1
+
const VOTE_COOLDOWN_HOURS = 12;
2
+
3
+
export async function checkVoteStatus(
4
+
_userId: string,
5
+
): Promise<{ hasVoted: boolean; nextVote: Date; voteCount: number }> {
6
+
const now = new Date();
7
+
const nextVote = new Date(now.getTime() + VOTE_COOLDOWN_HOURS * 60 * 60 * 1000);
8
+
9
+
return {
10
+
hasVoted: true,
11
+
nextVote,
12
+
voteCount: 1,
13
+
};
14
+
}
+1
-20
src/utils/userStrikes.ts
+1
-20
src/utils/userStrikes.ts
···
16
16
}
17
17
}
18
18
19
-
export async function getUserStrikeInfo(userId: string): Promise<StrikeInfo | null> {
19
+
async function _getUserStrikeInfo(userId: string): Promise<StrikeInfo | null> {
20
20
if (!userId || typeof userId !== 'string') {
21
21
throw new StrikeError('Invalid user ID provided');
22
22
}
···
152
152
throw new StrikeError('Failed to reset old strikes');
153
153
}
154
154
}
155
-
156
-
export async function clearUserStrikes(userId: string): Promise<boolean> {
157
-
if (!userId || typeof userId !== 'string') {
158
-
throw new StrikeError('Invalid user ID provided');
159
-
}
160
-
161
-
try {
162
-
const res = await pgClient.query(
163
-
'UPDATE user_strikes SET strike_count = 0, banned_until = NULL WHERE user_id = $1',
164
-
[userId],
165
-
);
166
-
167
-
logger.info('Cleared user strikes', { userId });
168
-
return (res.rowCount ?? 0) > 0;
169
-
} catch (error) {
170
-
logger.error('Failed to clear user strikes', { userId, error });
171
-
throw new StrikeError('Failed to clear strikes', userId);
172
-
}
173
-
}
+29
-22
src/utils/validation.ts
+29
-22
src/utils/validation.ts
···
118
118
return validator.isFQDN(domain, { require_tld: true });
119
119
}
120
120
121
-
function normalizeInput(text: string): string {
122
-
let normalized = text.toLowerCase();
123
-
normalized = normalized.replace(/([a-z])\1{2,}/g, '$1');
124
-
normalized = normalized
125
-
.replace(/[@4]/g, 'a')
126
-
.replace(/[3]/g, 'e')
127
-
.replace(/[1!]/g, 'i')
128
-
.replace(/[0]/g, 'o')
121
+
function _normalizeText(text: string): string {
122
+
if (!text) return '';
123
+
124
+
return text
125
+
.toLowerCase()
126
+
.replace(/([a-z])\1{2,}/g, '$1')
127
+
.replace(/[^\w\s]/g, '')
128
+
.replace(/@/g, 'a')
129
+
.replace(/4/g, 'a')
130
+
.replace(/3/g, 'e')
131
+
.replace(/1|!/g, 'i')
132
+
.replace(/0/g, 'o')
129
133
.replace(/[5$]/g, 's')
130
-
.replace(/[7]/g, 't');
131
-
return normalized;
134
+
.replace(/7/g, 't')
135
+
.trim();
132
136
}
133
137
134
138
export function getUnallowedWordCategory(text: string): string | null {
135
-
const normalized = normalizeInput(text);
136
-
for (const [category, words] of Object.entries(UNALLOWED_WORDS)) {
137
-
for (const word of words as string[]) {
138
-
if (category === 'slurs') {
139
-
if (normalized.includes(word)) {
140
-
return category;
141
-
}
142
-
} else {
143
-
const pattern = new RegExp(`(?:^|\\W)${word}[a-z]{0,2}(?:\\W|$)`, 'i');
144
-
if (pattern.test(normalized)) {
145
-
return category;
146
-
}
139
+
if (!text || typeof text !== 'string') return null;
140
+
141
+
const words = text
142
+
.toLowerCase()
143
+
.split(/[\s"'.,?!;:]+/)
144
+
.map((word) => word.replace(/[^\w\s]/g, ''))
145
+
.filter((word) => word.length > 0);
146
+
147
+
for (const word of words) {
148
+
if (word.length <= 2) continue;
149
+
150
+
for (const [category, wordList] of Object.entries(UNALLOWED_WORDS)) {
151
+
if ((wordList as string[]).some((badWord) => word.toLowerCase() === badWord.toLowerCase())) {
152
+
return category;
147
153
}
148
154
}
149
155
}
156
+
150
157
return null;
151
158
}
152
159
+215
src/utils/voteManager.ts
+215
src/utils/voteManager.ts
···
1
+
import pool from './pgClient';
2
+
import { Client, GatewayIntentBits } from 'discord.js';
3
+
import { checkVoteStatus } from './topgg';
4
+
import logger from './logger';
5
+
6
+
const VOTE_CREDITS = 10;
7
+
const VOTE_COOLDOWN_HOURS = 12;
8
+
9
+
export interface VoteResult {
10
+
success: boolean;
11
+
creditsAwarded: number;
12
+
nextVoteAvailable: Date;
13
+
}
14
+
15
+
export interface CreditsInfo {
16
+
remaining: number;
17
+
lastReset: Date;
18
+
}
19
+
20
+
async function _hasVotedToday(
21
+
userId: string,
22
+
serverId?: string,
23
+
): Promise<{ hasVoted: boolean; nextVote: Date }> {
24
+
try {
25
+
const localResult = await pool.query<{ vote_timestamp: Date }>(
26
+
`SELECT vote_timestamp FROM votes
27
+
WHERE user_id = $1
28
+
AND (server_id = $2 OR ($2 IS NULL AND server_id IS NULL))
29
+
ORDER BY vote_timestamp DESC
30
+
LIMIT 1`,
31
+
[userId, serverId],
32
+
);
33
+
34
+
if (localResult.rows.length > 0) {
35
+
const lastVote = new Date(localResult.rows[0].vote_timestamp);
36
+
const nextVote = new Date(lastVote.getTime() + VOTE_COOLDOWN_HOURS * 60 * 60 * 1000);
37
+
38
+
if (Date.now() < nextVote.getTime()) {
39
+
return { hasVoted: true, nextVote };
40
+
}
41
+
}
42
+
43
+
if (process.env.TOPGG_TOKEN) {
44
+
const voteStatus = await checkVoteStatus(userId);
45
+
if (voteStatus.hasVoted) {
46
+
await recordVoteInDatabase(userId, serverId);
47
+
return { hasVoted: true, nextVote: voteStatus.nextVote };
48
+
}
49
+
return { hasVoted: false, nextVote: voteStatus.nextVote };
50
+
}
51
+
52
+
return { hasVoted: false, nextVote: new Date() };
53
+
} catch (error) {
54
+
console.error('Error checking vote status:', error);
55
+
const result = await pool.query(
56
+
`SELECT 1 FROM votes
57
+
WHERE user_id = $1
58
+
AND (server_id = $2 OR ($2 IS NULL AND server_id IS NULL))
59
+
AND vote_timestamp > NOW() - INTERVAL '${VOTE_COOLDOWN_HOURS} hours'`,
60
+
[userId, serverId],
61
+
);
62
+
return {
63
+
hasVoted: result.rows.length > 0,
64
+
nextVote: new Date(Date.now() + VOTE_COOLDOWN_HOURS * 60 * 60 * 1000),
65
+
};
66
+
}
67
+
}
68
+
69
+
async function recordVoteInDatabase(userId: string, serverId?: string): Promise<void> {
70
+
const client = await pool.connect();
71
+
try {
72
+
await client.query('BEGIN');
73
+
74
+
await client.query(
75
+
`INSERT INTO votes (user_id, server_id, credits_awarded)
76
+
VALUES ($1, $2, $3)`,
77
+
[userId, serverId || null, VOTE_CREDITS],
78
+
);
79
+
80
+
await client.query('COMMIT');
81
+
} catch (error) {
82
+
await client.query('ROLLBACK');
83
+
throw error;
84
+
} finally {
85
+
client.release();
86
+
}
87
+
}
88
+
89
+
export async function recordVote(
90
+
userId: string,
91
+
serverId?: string,
92
+
): Promise<{ success: boolean; creditsAwarded: number; nextVoteAvailable: Date }> {
93
+
const client = await pool.connect();
94
+
try {
95
+
await client.query('BEGIN');
96
+
97
+
const existingVote = await client.query(
98
+
`SELECT vote_timestamp FROM votes
99
+
WHERE user_id = $1
100
+
AND (server_id = $2 OR ($2 IS NULL AND server_id IS NULL))
101
+
ORDER BY vote_timestamp DESC
102
+
LIMIT 1`,
103
+
[userId, serverId || null],
104
+
);
105
+
106
+
if (existingVote.rows.length > 0) {
107
+
const lastVoteTime = new Date(existingVote.rows[0].vote_timestamp).getTime();
108
+
const cooldownEnd = lastVoteTime + VOTE_COOLDOWN_HOURS * 60 * 60 * 1000;
109
+
110
+
if (Date.now() < cooldownEnd) {
111
+
return {
112
+
success: false,
113
+
creditsAwarded: 0,
114
+
nextVoteAvailable: new Date(cooldownEnd),
115
+
};
116
+
}
117
+
}
118
+
119
+
const voteStatus = await checkVoteStatus(userId);
120
+
if (!voteStatus.hasVoted) {
121
+
return {
122
+
success: false,
123
+
creditsAwarded: 0,
124
+
nextVoteAvailable: voteStatus.nextVote,
125
+
};
126
+
}
127
+
128
+
await client.query(
129
+
`INSERT INTO votes (user_id, server_id, credits_awarded)
130
+
VALUES ($1, $2, $3)`,
131
+
[userId, serverId || null, VOTE_CREDITS],
132
+
);
133
+
134
+
const clientBot = new Client({
135
+
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers],
136
+
});
137
+
138
+
try {
139
+
await clientBot.login(process.env.TOKEN);
140
+
const user = await clientBot.users.fetch(userId);
141
+
142
+
if (user) {
143
+
const guilds = await clientBot.guilds.fetch();
144
+
await Promise.all(
145
+
guilds.map(async (guild) => {
146
+
try {
147
+
const fullGuild = await guild.fetch();
148
+
const member = await fullGuild.members.fetch(userId).catch(() => null);
149
+
150
+
if (member) {
151
+
logger.debug(
152
+
`User ${userId} is member of server ${guild.id} - vote benefits apply`,
153
+
);
154
+
}
155
+
} catch (error) {
156
+
logger.error(`Error processing guild ${guild.id}:`, error);
157
+
}
158
+
}),
159
+
);
160
+
}
161
+
} catch (error) {
162
+
logger.error('Error in vote processing:', error);
163
+
} finally {
164
+
clientBot.destroy().catch((err) => logger.error('Error destroying bot client:', err));
165
+
}
166
+
167
+
logger.info(`User ${userId} voted - AI system will give +10 daily limit`);
168
+
169
+
try {
170
+
const clientBot = new Client({
171
+
intents: [
172
+
GatewayIntentBits.Guilds,
173
+
GatewayIntentBits.GuildMembers,
174
+
GatewayIntentBits.MessageContent,
175
+
],
176
+
});
177
+
178
+
await clientBot.login(process.env.TOKEN);
179
+
const user = await clientBot.users.fetch(userId);
180
+
181
+
if (user) {
182
+
const nextVoteTime = Math.floor((Date.now() + 12 * 60 * 60 * 1000) / 1000);
183
+
await user
184
+
.send(
185
+
`๐ **Thank you for voting for Aethel!**\n` +
186
+
`\n` +
187
+
`You've received **+10 AI daily limit** for today!\n` +
188
+
`\n` +
189
+
`You can vote again <t:${nextVoteTime}:R>\n` +
190
+
`\n` +
191
+
`Thank you for your support! โค๏ธ`,
192
+
)
193
+
.catch((err) => logger.error('Failed to send vote DM:', err));
194
+
}
195
+
196
+
clientBot.destroy().catch((err) => logger.error('Error destroying bot client:', err));
197
+
} catch (error) {
198
+
logger.error('Failed to send vote thank you DM:', error);
199
+
}
200
+
201
+
await client.query('COMMIT');
202
+
203
+
return {
204
+
success: true,
205
+
creditsAwarded: 10,
206
+
nextVoteAvailable: voteStatus.nextVote,
207
+
};
208
+
} catch (error) {
209
+
await client.query('ROLLBACK');
210
+
logger.error('Error recording vote:', error);
211
+
throw new Error('Failed to record your vote. Please try again later.');
212
+
} finally {
213
+
client.release();
214
+
}
215
+
}
+28
-1
web/src/lib/api.ts
+28
-1
web/src/lib/api.ts
···
2
2
import { toast } from 'sonner';
3
3
4
4
const api = axios.create({
5
-
baseURL: `${import.meta.env.VITE_FRONTEND_URL}/api`,
5
+
baseURL: `/api`,
6
6
timeout: 10000,
7
7
});
8
8
···
61
61
deleteApiKey: () => api.delete('/user/api-keys'),
62
62
testApiKey: (data: { apiKey: string; model?: string; apiUrl?: string }) =>
63
63
api.post('/user/api-keys/test', data),
64
+
getModels: async ({ apiKey, apiUrl }: { apiKey: string; apiUrl?: string }) => {
65
+
try {
66
+
let baseUrl = apiUrl || 'https://api.openai.com/v1';
67
+
if (!baseUrl.endsWith('/v1')) {
68
+
baseUrl = baseUrl.endsWith('/') ? `${baseUrl}v1` : `${baseUrl}/v1`;
69
+
}
70
+
71
+
const response = await axios.get(`${baseUrl}/models`, {
72
+
headers: {
73
+
Authorization: `Bearer ${apiKey}`,
74
+
'Content-Type': 'application/json',
75
+
},
76
+
timeout: 10000,
77
+
});
78
+
79
+
interface ModelResponse {
80
+
id: string;
81
+
[key: string]: unknown;
82
+
}
83
+
84
+
const models = (response.data?.data as ModelResponse[])?.map((model) => model.id) || [];
85
+
return { data: { models } };
86
+
} catch (error) {
87
+
console.error('Failed to fetch models from provider API:', error);
88
+
return { data: { models: [] } };
89
+
}
90
+
},
64
91
};
65
92
66
93
export const remindersAPI = {
+387
-168
web/src/pages/ApiKeysPage.tsx
+387
-168
web/src/pages/ApiKeysPage.tsx
···
1
-
import { useState } from 'react';
1
+
import React, { useState, useEffect } from 'react';
2
2
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
3
-
import { Key, Eye, EyeOff, TestTube, Save, Trash2, AlertCircle, CheckCircle } from 'lucide-react';
3
+
import {
4
+
Key,
5
+
Eye,
6
+
EyeOff,
7
+
TestTube,
8
+
Save,
9
+
Trash2,
10
+
AlertCircle,
11
+
CheckCircle,
12
+
Loader2,
13
+
} from 'lucide-react';
4
14
import { toast } from 'sonner';
5
15
import { apiKeysAPI } from '../lib/api';
6
16
17
+
interface ApiKeyInfo {
18
+
apiKey?: string;
19
+
model?: string;
20
+
apiUrl?: string;
21
+
hasApiKey: boolean;
22
+
}
23
+
24
+
interface FormData {
25
+
apiKey: string;
26
+
model: string;
27
+
apiUrl: string;
28
+
}
29
+
30
+
interface TestResult {
31
+
success: boolean;
32
+
message: string;
33
+
}
34
+
7
35
const ApiKeysPage = () => {
8
36
const [showApiKey, setShowApiKey] = useState(false);
9
-
const [formData, setFormData] = useState({
37
+
const [isLoadingModels, setIsLoadingModels] = useState(false);
38
+
const [availableModels, setAvailableModels] = useState<string[]>([]);
39
+
const [showModelDropdown, setShowModelDropdown] = useState(false);
40
+
const [modelSearch, setModelSearch] = useState('');
41
+
const [isEditing, setIsEditing] = useState(false);
42
+
const [hasPassedTest, setHasPassedTest] = useState(false);
43
+
const [testResult, setTestResult] = useState<TestResult | null>(null);
44
+
const [formData, setFormData] = useState<FormData>({
10
45
apiKey: '',
11
46
model: '',
12
-
apiUrl: '',
47
+
apiUrl: 'https://api.openai.com/v1',
13
48
});
14
-
const [isEditing, setIsEditing] = useState(false);
15
-
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
16
-
const [hasPassedTest, setHasPassedTest] = useState(false);
17
-
const queryClient = useQueryClient();
18
49
19
-
const { data: apiKeyInfo, isLoading } = useQuery({
20
-
queryKey: ['api-keys'],
21
-
queryFn: () => apiKeysAPI.getApiKeys().then((res) => res.data),
22
-
});
50
+
const filteredModels = React.useMemo(() => {
51
+
if (!modelSearch) return availableModels;
52
+
const searchTerm = modelSearch.toLowerCase();
53
+
return availableModels.filter((model) => model.toLowerCase().includes(searchTerm));
54
+
}, [availableModels, modelSearch]);
23
55
24
-
const updateApiKeyMutation = useMutation({
25
-
mutationFn: (data: { apiKey: string; model?: string; apiUrl?: string }) =>
26
-
apiKeysAPI.updateApiKey(data),
27
-
onSuccess: () => {
28
-
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
29
-
setIsEditing(false);
30
-
setFormData({ apiKey: '', model: '', apiUrl: '' });
31
-
toast.success('API key updated successfully!');
32
-
},
33
-
onError: () => {
34
-
toast.error('Failed to update API key');
35
-
},
36
-
});
56
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
57
+
const { name, value } = e.target;
58
+
setFormData((prev) => ({
59
+
...prev,
60
+
[name]: value,
61
+
}));
62
+
};
37
63
38
-
const deleteApiKeyMutation = useMutation({
39
-
mutationFn: () => apiKeysAPI.deleteApiKey(),
40
-
onSuccess: () => {
41
-
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
42
-
setFormData({ apiKey: '', model: '', apiUrl: '' });
43
-
setIsEditing(false);
44
-
toast.success('API key deleted successfully!');
45
-
},
46
-
onError: () => {
47
-
toast.error('Failed to delete API key');
48
-
},
49
-
});
64
+
const handleModelSelect = (model: string) => {
65
+
setFormData((prev) => ({
66
+
...prev,
67
+
model,
68
+
}));
69
+
setModelSearch('');
70
+
setShowModelDropdown(false);
71
+
};
50
72
51
-
const testApiKeyMutation = useMutation({
52
-
mutationFn: (data: { apiKey: string; model?: string; apiUrl?: string }) =>
53
-
apiKeysAPI.testApiKey(data),
54
-
onSuccess: () => {
55
-
setTestResult({ success: true, message: 'API key is valid and working!' });
56
-
setHasPassedTest(true);
57
-
toast.success('API key test successful!');
58
-
},
59
-
onError: (error: { response?: { data?: { error?: string } } }) => {
60
-
const message = error.response?.data?.error || 'API key test failed';
61
-
setTestResult({ success: false, message });
62
-
setHasPassedTest(false);
63
-
toast.error(message);
64
-
},
65
-
});
73
+
const handleModelInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
74
+
const value = e.target.value;
75
+
setFormData((prev) => ({ ...prev, model: value }));
76
+
setModelSearch(value);
77
+
setHasPassedTest(false);
78
+
setTestResult(null);
79
+
setShowModelDropdown(true);
66
80
67
-
const handleSubmit = (e: React.FormEvent) => {
68
-
e.preventDefault();
69
-
if (!formData.apiKey.trim()) {
70
-
toast.error('API key is required');
71
-
return;
81
+
if (availableModels.length === 0 && formData.apiKey) {
82
+
fetchModels();
72
83
}
73
-
if (!hasPassedTest) {
74
-
toast.error('Please test the API key before saving');
75
-
return;
84
+
};
85
+
86
+
const fetchModels = async () => {
87
+
if (!formData.apiKey) return;
88
+
89
+
setIsLoadingModels(true);
90
+
try {
91
+
const response = await apiKeysAPI.getModels({
92
+
apiKey: formData.apiKey,
93
+
apiUrl: formData.apiUrl,
94
+
});
95
+
96
+
if (response.data?.models?.length > 0) {
97
+
setAvailableModels(response.data.models);
98
+
} else {
99
+
setAvailableModels([]);
100
+
}
101
+
} catch (error) {
102
+
console.error('Error fetching models:', error);
103
+
setAvailableModels([]);
104
+
} finally {
105
+
setIsLoadingModels(false);
76
106
}
77
-
updateApiKeyMutation.mutate({
78
-
apiKey: formData.apiKey,
79
-
model: formData.model || undefined,
80
-
apiUrl: formData.apiUrl || undefined,
81
-
});
82
107
};
83
108
84
-
const handleTest = () => {
85
-
if (!formData.apiKey.trim()) {
86
-
toast.error('API key is required for testing');
109
+
const handleSubmit = async (e: React.FormEvent) => {
110
+
e.preventDefault();
111
+
112
+
if (hasPassedTest) {
113
+
updateApiKeyMutation.mutate({
114
+
apiKey: formData.apiKey,
115
+
model: formData.model,
116
+
apiUrl: formData.apiUrl,
117
+
});
87
118
return;
88
119
}
89
-
setTestResult(null);
90
-
testApiKeyMutation.mutate({
91
-
apiKey: formData.apiKey,
92
-
model: formData.model || undefined,
93
-
apiUrl: formData.apiUrl || undefined,
94
-
});
120
+
121
+
try {
122
+
const result = await testApiKeyMutation.mutateAsync({
123
+
apiKey: formData.apiKey,
124
+
model: formData.model,
125
+
apiUrl: formData.apiUrl,
126
+
});
127
+
128
+
if (result.data?.success) {
129
+
updateApiKeyMutation.mutate({
130
+
apiKey: formData.apiKey,
131
+
model: formData.model,
132
+
apiUrl: formData.apiUrl,
133
+
});
134
+
}
135
+
} catch (_error) {
136
+
// ignore
137
+
}
95
138
};
96
139
97
140
const handleEdit = () => {
98
141
setIsEditing(true);
99
142
setFormData({
100
-
apiKey: '',
143
+
apiKey: apiKeyInfo?.apiKey || '',
101
144
model: apiKeyInfo?.model || '',
102
-
apiUrl: apiKeyInfo?.apiUrl || '',
145
+
apiUrl: apiKeyInfo?.apiUrl || 'https://api.openai.com/v1',
103
146
});
104
147
setTestResult(null);
105
148
setHasPassedTest(false);
···
107
150
108
151
const handleCancel = () => {
109
152
setIsEditing(false);
110
-
setFormData({ apiKey: '', model: '', apiUrl: '' });
153
+
setFormData({
154
+
apiKey: apiKeyInfo?.apiKey || '',
155
+
model: apiKeyInfo?.model || '',
156
+
apiUrl: apiKeyInfo?.apiUrl || 'https://api.openai.com/v1',
157
+
});
111
158
setTestResult(null);
112
159
setHasPassedTest(false);
113
160
};
114
161
162
+
const queryClient = useQueryClient();
163
+
164
+
const { data: apiKeyInfo, isLoading } = useQuery<ApiKeyInfo>({
165
+
queryKey: ['api-keys'],
166
+
queryFn: () => apiKeysAPI.getApiKeys().then((res) => res.data as ApiKeyInfo),
167
+
});
168
+
169
+
useEffect(() => {
170
+
if (apiKeyInfo) {
171
+
setFormData({
172
+
apiKey: apiKeyInfo.apiKey || '',
173
+
model: apiKeyInfo.model || '',
174
+
apiUrl: apiKeyInfo.apiUrl || 'https://api.openai.com/v1',
175
+
});
176
+
}
177
+
}, [apiKeyInfo]);
178
+
179
+
const updateApiKeyMutation = useMutation({
180
+
mutationFn: (data: { apiKey?: string; model?: string; apiUrl?: string }) =>
181
+
apiKeysAPI.updateApiKey(data),
182
+
onSuccess: () => {
183
+
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
184
+
toast.success('API key updated successfully');
185
+
setIsEditing(false);
186
+
},
187
+
onError: (error: unknown) => {
188
+
const errorMessage =
189
+
error && typeof error === 'object' && 'response' in error
190
+
? (error as { response?: { data?: { error?: string } } })?.response?.data?.error
191
+
: 'Failed to update API key';
192
+
toast.error(errorMessage);
193
+
},
194
+
});
195
+
const testApiKeyMutation = useMutation({
196
+
mutationFn: (data: { apiKey: string; model?: string; apiUrl?: string }) =>
197
+
apiKeysAPI.testApiKey(data),
198
+
onSuccess: async (_, variables) => {
199
+
setHasPassedTest(true);
200
+
setTestResult({
201
+
success: true,
202
+
message: 'API key test successful!',
203
+
});
204
+
toast.success('API key test successful!');
205
+
206
+
if (variables.apiKey) {
207
+
try {
208
+
setIsLoadingModels(true);
209
+
const modelsResponse = await apiKeysAPI.getModels({
210
+
apiKey: variables.apiKey,
211
+
apiUrl: variables.apiUrl,
212
+
});
213
+
if (modelsResponse.data?.models?.length > 0) {
214
+
setAvailableModels(modelsResponse.data.models);
215
+
} else {
216
+
setAvailableModels([]);
217
+
}
218
+
} catch (error) {
219
+
console.error('Error fetching models:', error);
220
+
setAvailableModels([]);
221
+
} finally {
222
+
setIsLoadingModels(false);
223
+
}
224
+
}
225
+
},
226
+
onError: (error: unknown) => {
227
+
const errorMessage =
228
+
error && typeof error === 'object' && 'response' in error
229
+
? (error as { response?: { data?: { error?: string | { message: string } } } })?.response
230
+
?.data?.error
231
+
: undefined;
232
+
const message =
233
+
typeof errorMessage === 'object'
234
+
? errorMessage?.message
235
+
: errorMessage || 'API key test failed';
236
+
setHasPassedTest(false);
237
+
setTestResult({ success: false, message });
238
+
toast.error(message);
239
+
},
240
+
});
241
+
242
+
const deleteApiKeyMutation = useMutation({
243
+
mutationFn: () => apiKeysAPI.deleteApiKey(),
244
+
onSuccess: () => {
245
+
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
246
+
toast.success('API key deleted successfully');
247
+
setIsEditing(false);
248
+
setHasPassedTest(false);
249
+
setTestResult(null);
250
+
setFormData({
251
+
apiKey: '',
252
+
model: '',
253
+
apiUrl: 'https://api.openai.com/v1',
254
+
});
255
+
},
256
+
onError: (error: unknown) => {
257
+
const errorMessage =
258
+
error && typeof error === 'object' && 'response' in error
259
+
? (error as { response?: { data?: { error?: string } } })?.response?.data?.error
260
+
: 'Failed to delete API key';
261
+
toast.error(errorMessage);
262
+
},
263
+
});
264
+
115
265
if (isLoading) {
116
266
return (
117
267
<div className="flex items-center justify-center h-64">
···
216
366
<span className="text-red-600 dark:text-red-400">*</span> You must test the API key
217
367
before saving to ensure it works correctly.
218
368
</p>
219
-
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
220
-
API Key *
221
-
</label>
222
-
<div className="relative">
223
-
<input
224
-
type={showApiKey ? 'text' : 'password'}
225
-
value={formData.apiKey}
369
+
370
+
<div className="mb-4">
371
+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
372
+
AI Provider *
373
+
</label>
374
+
<select
375
+
value={formData.apiUrl}
226
376
onChange={(e) => {
227
-
setFormData({ ...formData, apiKey: e.target.value });
377
+
const url = e.target.value;
378
+
setFormData({ ...formData, apiUrl: url, model: '' });
379
+
setAvailableModels([]);
228
380
setHasPassedTest(false);
229
381
setTestResult(null);
230
382
}}
231
-
placeholder="Enter your API key"
232
-
className="input pr-10 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100 dark:placeholder-gray-400"
233
-
required
234
-
/>
235
-
<button
236
-
type="button"
237
-
onClick={() => setShowApiKey(!showApiKey)}
238
-
className="absolute inset-y-0 right-0 pr-3 flex items-center"
383
+
className="input dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100 w-full"
239
384
>
240
-
{showApiKey ? (
241
-
<EyeOff className="h-4 w-4 text-gray-400" />
242
-
) : (
243
-
<Eye className="h-4 w-4 text-gray-400" />
244
-
)}
245
-
</button>
385
+
<option value="https://api.openai.com/v1">OpenAI (api.openai.com/v1)</option>
386
+
<option value="https://openrouter.ai/api/v1">
387
+
OpenRouter (openrouter.ai/api/v1)
388
+
</option>
389
+
<option value="https://api.anthropic.com/v1">
390
+
Anthropic Claude (api.anthropic.com/v1)
391
+
</option>
392
+
<option value="https://api.mistral.ai/v1">Mistral AI (api.mistral.ai/v1)</option>
393
+
<option value="https://api.deepseek.com/v1">
394
+
DeepSeek (api.deepseek.com/v1)
395
+
</option>
396
+
<option value="https://api.together.xyz/v1">
397
+
Together AI (api.together.xyz/v1)
398
+
</option>
399
+
<option value="https://api.perplexity.ai/v1">
400
+
Perplexity AI (api.perplexity.ai/v1)
401
+
</option>
402
+
<option value="https://generativelanguage.googleapis.com/v1beta">
403
+
Google Gemini (generativelanguage.googleapis.com)
404
+
</option>
405
+
<option value="https://api.groq.com/openai/v1">
406
+
Groq (api.groq.com/openai/v1)
407
+
</option>
408
+
<option value="https://api.lepton.ai/v1">Lepton AI (api.lepton.ai/v1)</option>
409
+
<option value="https://api.deepinfra.com/v1/openai">
410
+
DeepInfra (api.deepinfra.com/v1/openai)
411
+
</option>
412
+
<option value="https://api.x.ai/v1">xAI (api.x.ai/v1)</option>
413
+
<option value="https://api.moonshot.ai/v1">
414
+
Moonshot AI (api.moonshot.ai/v1)
415
+
</option>
416
+
</select>
417
+
</div>
418
+
419
+
<div className="mb-4">
420
+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
421
+
API Key *
422
+
</label>
423
+
<div className="relative">
424
+
<input
425
+
type={showApiKey ? 'text' : 'password'}
426
+
name="apiKey"
427
+
value={formData.apiKey}
428
+
onChange={(e) => {
429
+
handleInputChange(e);
430
+
setHasPassedTest(false);
431
+
setTestResult(null);
432
+
}}
433
+
placeholder="Enter your API key"
434
+
className="input pr-10 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100 dark:placeholder-gray-400 w-full"
435
+
required
436
+
/>
437
+
<button
438
+
type="button"
439
+
onClick={() => setShowApiKey(!showApiKey)}
440
+
className="absolute inset-y-0 right-0 pr-3 flex items-center"
441
+
>
442
+
{showApiKey ? (
443
+
<EyeOff className="h-4 w-4 text-gray-400" />
444
+
) : (
445
+
<Eye className="h-4 w-4 text-gray-400" />
446
+
)}
447
+
</button>
448
+
</div>
246
449
</div>
247
450
</div>
248
451
249
-
<div>
452
+
<div className="relative">
250
453
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
251
454
Model (Optional)
252
455
</label>
253
-
<input
254
-
type="text"
255
-
value={formData.model}
256
-
onChange={(e) => {
257
-
setFormData({ ...formData, model: e.target.value });
258
-
setHasPassedTest(false);
259
-
setTestResult(null);
260
-
}}
261
-
placeholder="e.g., openai/gpt-4o-mini, anthropic/claude-4-sonnet"
262
-
className="input dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100 dark:placeholder-gray-400"
263
-
/>
264
-
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
265
-
Leave empty to use the default model
266
-
</p>
267
-
</div>
268
-
269
-
<div>
270
-
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
271
-
API Endpoint URL (Optional)
272
-
</label>
273
-
<input
274
-
type="url"
275
-
value={formData.apiUrl}
276
-
onChange={(e) => {
277
-
setFormData({ ...formData, apiUrl: e.target.value });
278
-
setHasPassedTest(false);
279
-
setTestResult(null);
280
-
}}
281
-
placeholder="https://openrouter.ai/api/v1"
282
-
className="input dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100 dark:placeholder-gray-400"
283
-
/>
284
-
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
285
-
Enter the base API URL (e.g., https://openrouter.ai/api/v1,
286
-
https://api.openai.com/v1)
287
-
</p>
456
+
<div className="relative">
457
+
<input
458
+
type="text"
459
+
name="model"
460
+
value={formData.model}
461
+
onFocus={() => setShowModelDropdown(true)}
462
+
onChange={handleModelInputChange}
463
+
onBlur={() => {
464
+
setTimeout(() => setShowModelDropdown(false), 200);
465
+
}}
466
+
placeholder="Select or type a model name"
467
+
className="model-input input dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100 dark:placeholder-gray-400 w-full pr-8"
468
+
/>
469
+
{isLoadingModels && (
470
+
<div className="absolute right-3 top-1/2 -translate-y-1/2">
471
+
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
472
+
</div>
473
+
)}
474
+
{showModelDropdown && (availableModels.length > 0 || modelSearch) && (
475
+
<div className="absolute z-10 mt-1 w-full rounded-md bg-white dark:bg-gray-800 shadow-lg border border-gray-200 dark:border-gray-700 max-h-60 overflow-auto">
476
+
{filteredModels.length > 0 ? (
477
+
filteredModels.map((model: string) => (
478
+
<div
479
+
key={model}
480
+
className="px-4 py-2 text-sm text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer"
481
+
onMouseDown={(e) => {
482
+
e.preventDefault();
483
+
handleModelSelect(model);
484
+
}}
485
+
>
486
+
{model}
487
+
</div>
488
+
))
489
+
) : (
490
+
<div className="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">
491
+
No matching models found
492
+
</div>
493
+
)}
494
+
</div>
495
+
)}
496
+
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
497
+
{availableModels.length > 0
498
+
? `Found ${availableModels.length} models`
499
+
: 'Type to search or leave empty for default'}
500
+
</p>
501
+
</div>
288
502
</div>
289
503
290
504
{testResult && (
···
304
518
</div>
305
519
)}
306
520
307
-
<div className="flex flex-col sm:flex-row sm:justify-between gap-3 pt-4">
521
+
<div className="flex flex-col sm:flex-row justify-end gap-3 pt-4">
308
522
<button
309
523
type="button"
310
-
onClick={handleTest}
311
-
disabled={!formData.apiKey.trim() || testApiKeyMutation.isPending}
312
-
className="btn btn-secondary active:scale-95 transition-transform order-1 sm:order-none"
524
+
onClick={handleCancel}
525
+
className="btn btn-secondary active:scale-95 transition-transform"
526
+
disabled={updateApiKeyMutation.isPending || testApiKeyMutation.isPending}
527
+
>
528
+
Cancel
529
+
</button>
530
+
<button
531
+
type="submit"
532
+
disabled={
533
+
!formData.apiKey.trim() ||
534
+
updateApiKeyMutation.isPending ||
535
+
testApiKeyMutation.isPending
536
+
}
537
+
className={`btn active:scale-95 transition-transform ${
538
+
hasPassedTest ? 'btn-primary' : 'btn-secondary'
539
+
}`}
313
540
>
314
-
<TestTube className="h-4 w-4 mr-2" />
315
-
{testApiKeyMutation.isPending ? 'Testing...' : 'Test API Key'}
541
+
{testApiKeyMutation.isPending ? (
542
+
<>
543
+
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
544
+
Testing...
545
+
</>
546
+
) : updateApiKeyMutation.isPending ? (
547
+
<>
548
+
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
549
+
Saving...
550
+
</>
551
+
) : hasPassedTest ? (
552
+
<>
553
+
<Save className="h-4 w-4 mr-2" />
554
+
Save
555
+
</>
556
+
) : (
557
+
<>
558
+
<TestTube className="h-4 w-4 mr-2" />
559
+
Test & Save
560
+
</>
561
+
)}
316
562
</button>
317
-
318
-
<div className="flex flex-col sm:flex-row gap-3 sm:space-x-3 sm:gap-0">
319
-
<button
320
-
type="button"
321
-
onClick={handleCancel}
322
-
className="btn btn-secondary active:scale-95 transition-transform"
323
-
disabled={updateApiKeyMutation.isPending}
324
-
>
325
-
Cancel
326
-
</button>
327
-
<button
328
-
type="submit"
329
-
disabled={
330
-
!formData.apiKey.trim() || updateApiKeyMutation.isPending || !hasPassedTest
331
-
}
332
-
className={`btn active:scale-95 transition-transform ${
333
-
hasPassedTest ? 'btn-primary' : 'btn-secondary opacity-50 cursor-not-allowed'
334
-
}`}
335
-
>
336
-
<Save className="h-4 w-4 mr-2" />
337
-
{updateApiKeyMutation.isPending
338
-
? 'Saving...'
339
-
: hasPassedTest
340
-
? 'Save'
341
-
: 'Test Required'}
342
-
</button>
343
-
</div>
344
563
</div>
345
564
</form>
346
565
</div>
+1
-1
web/src/pages/LandingPage.tsx
+1
-1
web/src/pages/LandingPage.tsx
···
205
205
<div className="mb-4">
206
206
<p className="text-xs text-gray-600 dark:text-gray-400 mb-2">Powered by</p>
207
207
<a
208
-
href="https://royalehosting.net?utm_source=aethel.xyz&utm_medium=referral&utm_campaign=powered_by&utm_content=footer"
208
+
href="https://royalehosting.net/?aff=8033?utm_source=aethel.xyz&utm_medium=referral&utm_campaign=powered_by&utm_content=footer"
209
209
target="_blank"
210
210
rel="noopener noreferrer"
211
211
className="inline-block hover:opacity-80 transition-opacity"
+2
-2
web/src/pages/LoginPage.tsx
+2
-2
web/src/pages/LoginPage.tsx
···
28
28
29
29
const handleDiscordLogin = async () => {
30
30
try {
31
-
window.location.href = `${import.meta.env.VITE_FRONTEND_URL}/api/auth/discord`;
31
+
window.location.href = `/api/auth/discord`;
32
32
} catch (_error) {
33
33
toast.error('Failed to initiate Discord login');
34
34
}
···
66
66
67
67
<button
68
68
onClick={handleDiscordLogin}
69
-
className="w-full flex items-center justify-center px-8 py-3 border border-transparent rounded-full shadow-sm text-white bg-[#5865F2] hover:bg-[#4752c4] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#5865F2] transition-all transform hover:scale-105 font-bold shadow-lg hover:shadow-xl"
69
+
className="w-full flex items-center justify-center px-8 py-3 border border-transparent rounded-full shadow-sm text-white bg-[#5865F2] hover:bg-[#4752c4] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#5865F2] transition-all transform hover:scale-105 font-bold hover:shadow-xl"
70
70
>
71
71
<svg
72
72
className="w-5 h-5 mr-3"
+1
-1
web/src/pages/PrivacyPage.tsx
+1
-1
web/src/pages/PrivacyPage.tsx
···
178
178
<p className="text-gray-700 dark:text-gray-300 leading-relaxed">
179
179
We implement the following security measures to protect your information:
180
180
</p>
181
-
<ul className="list-disc pl-6 space-y-3 text-gray-700 mt-2">
181
+
<ul className="list-disc pl-6 space-y-3 text-gray-700 dark:text-gray-300 mt-2">
182
182
<li>
183
183
<strong>API Key Encryption:</strong> AES-256-GCM encryption for all stored API keys
184
184
</li>
+44
-7
web/src/pages/TermsPage.tsx
+44
-7
web/src/pages/TermsPage.tsx
···
4
4
return (
5
5
<LegalLayout
6
6
title="Terms of Service"
7
-
lastUpdated="June 16, 2025"
7
+
lastUpdated="November 9, 2025"
8
8
>
9
9
<div className="space-y-8">
10
10
<section>
···
32
32
<li>Random cat and dog images</li>
33
33
<li>Weather information</li>
34
34
<li>Wiki lookups</li>
35
+
<li>Informational stock snapshots</li>
35
36
<li>And other Discord utilities</li>
36
37
</ul>
37
38
</section>
···
54
55
55
56
<section>
56
57
<h2 className="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">
57
-
4. API Usage
58
+
4. Financial Data & /stocks Command
59
+
</h2>
60
+
<div className="space-y-4 text-gray-700 dark:text-gray-300 leading-relaxed">
61
+
<p>
62
+
The /stocks command and any other financial utilities are provided for informational
63
+
purposes only. We do not offer investment advice, brokerage services, or any tools for
64
+
trading automation. By using these features you acknowledge and agree that:
65
+
</p>
66
+
<ul className="list-disc pl-6 space-y-3">
67
+
<li>
68
+
You will not rely on the Bot for investment, legal, tax, or other professional
69
+
advice.
70
+
</li>
71
+
<li>
72
+
You will not use the Bot to attempt to manipulate any financial market, coordinate
73
+
trading activity, or distribute misleading information.
74
+
</li>
75
+
<li>
76
+
All output is delayed, may be inaccurate, and is intended solely for personal,
77
+
non-commercial use.
78
+
</li>
79
+
<li>
80
+
You are solely responsible for complying with applicable securities laws, exchange
81
+
policies, and platform rules.
82
+
</li>
83
+
<li>
84
+
We may throttle, modify, or disable financial data access at any time without
85
+
notice.
86
+
</li>
87
+
</ul>
88
+
</div>
89
+
</section>
90
+
91
+
<section>
92
+
<h2 className="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">
93
+
5. API Usage
58
94
</h2>
59
95
<p className="text-gray-700 dark:text-gray-300 leading-relaxed">
60
96
The Bot may use third-party APIs and services ("Third-Party Services"). Your
···
63
99
<ul className="list-disc pl-6 space-y-3 text-gray-700 dark:text-gray-300 mt-2">
64
100
<li>You are responsible for the security of your API keys</li>
65
101
<li>
66
-
We do not store your API keys permanently - they are only kept in memory during your
67
-
active session
102
+
Your API keys are stored securely using industry-standard encryption and are only
103
+
accessible to you
68
104
</li>
105
+
<li>You can delete your API keys at any time through the API keys management page</li>
69
106
<li>
70
107
You must comply with the terms of service of any third-party APIs you use with the Bot
71
108
</li>
···
78
115
79
116
<section>
80
117
<h2 className="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">
81
-
5. Limitation of Liability
118
+
6. Limitation of Liability
82
119
</h2>
83
120
<p className="text-gray-700 dark:text-gray-300 leading-relaxed">
84
121
The Bot is provided "as is" without any warranties. We are not responsible for
···
89
126
90
127
<section>
91
128
<h2 className="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">
92
-
6. Changes to Terms
129
+
7. Changes to Terms
93
130
</h2>
94
131
<p className="text-gray-700 dark:text-gray-300 leading-relaxed">
95
132
We reserve the right to modify these terms at any time. Continued use of the Bot after
···
99
136
100
137
<section>
101
138
<h2 className="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">
102
-
7. Contact
139
+
8. Contact
103
140
</h2>
104
141
<p className="text-gray-700 dark:text-gray-300 leading-relaxed">
105
142
If you have any questions about these Terms of Service, please contact us at{' '}
+1
-1
web/src/stores/authStore.ts
+1
-1
web/src/stores/authStore.ts