Compare commits
21 commits
Author | SHA1 | Date | |
---|---|---|---|
269b858e88 | |||
d300f20b49 | |||
75d3dab85e | |||
8cfa75ec57 | |||
0ba0181e2b | |||
0f36203c1c | |||
4ff0577906 | |||
53a1bb7d6b | |||
49ab7d6f19 | |||
50c5d5d551 | |||
9d7bd605b7 | |||
e4af3be2ad | |||
891d61b2ef | |||
a1dae32f80 | |||
db53308044 | |||
45d9053aea | |||
dd4a96cea4 | |||
881d4a0869 | |||
72a660821a | |||
c73b8725c1 | |||
cbd92de7a5 |
|
@ -4,3 +4,6 @@ PORT=8080
|
||||||
|
|
||||||
REDIS_URL=redis://username:password@localhost:6379
|
REDIS_URL=redis://username:password@localhost:6379
|
||||||
REDIS_TTL=3600 # seconds
|
REDIS_TTL=3600 # seconds
|
||||||
|
|
||||||
|
# if you wish to get discord badges
|
||||||
|
DISCORD_TOKEN=discord_bot_token
|
||||||
|
|
1
.gitignore
vendored
|
@ -2,3 +2,4 @@
|
||||||
bun.lock
|
bun.lock
|
||||||
.env
|
.env
|
||||||
.vscode/settings.json
|
.vscode/settings.json
|
||||||
|
logs
|
||||||
|
|
41
LICENSE
|
@ -1,21 +1,28 @@
|
||||||
MIT License
|
BSD 3-Clause License
|
||||||
|
|
||||||
Copyright (c) 2025 [fullname]
|
Copyright (c) 2025, creations.works
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Redistribution and use in source and binary forms, with or without
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
modification, are permitted provided that the following conditions are met:
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
1. Redistributions of source code must retain the above copyright notice, this
|
||||||
copies or substantial portions of the Software.
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
this list of conditions and the following disclaimer in the documentation
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
and/or other materials provided with the distribution.
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
3. Neither the name of the copyright holder nor the names of its
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
contributors may be used to endorse or promote products derived from
|
||||||
SOFTWARE.
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
30
README.md
|
@ -2,6 +2,9 @@
|
||||||
|
|
||||||
A fast Discord badge aggregation API built with [Bun](https://bun.sh) and Redis caching.
|
A fast Discord badge aggregation API built with [Bun](https://bun.sh) and Redis caching.
|
||||||
|
|
||||||
|
# Preview
|
||||||
|
https://badges.atums.world
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Aggregates custom badge data from multiple sources (e.g. Vencord, Nekocord, Equicord, etc.)
|
- Aggregates custom badge data from multiple sources (e.g. Vencord, Nekocord, Equicord, etc.)
|
||||||
|
@ -37,6 +40,9 @@ REDIS_URL=redis://username:password@localhost:6379
|
||||||
|
|
||||||
# Value is in seconds
|
# Value is in seconds
|
||||||
REDIS_TTL=3600
|
REDIS_TTL=3600
|
||||||
|
|
||||||
|
#only use this if you want to show discord badges
|
||||||
|
DISCORD_TOKEN=discord_bot_token
|
||||||
```
|
```
|
||||||
|
|
||||||
## Endpoint
|
## Endpoint
|
||||||
|
@ -53,11 +59,11 @@ GET /:userId
|
||||||
|
|
||||||
### Query Parameters
|
### Query Parameters
|
||||||
|
|
||||||
| Name | Description |
|
| Name | Description |
|
||||||
|--------------|--------------------------------------------------------------------------|
|
|--------------|---------------------------------------------------------------------------------------------------|
|
||||||
| `services` | A comma or space separated list of services to fetch badges from |
|
| `services` | A comma or space separated list of services to fetch badges from, if this is empty it fetches all |
|
||||||
| `cache` | Set to `true` or `false` (default: `true`). `false` bypasses Redis |
|
| `cache` | Set to `true` or `false` (default: `true`). `false` bypasses Redis |
|
||||||
| `seperated` | Set to `true` to return results grouped by service, else merged array |
|
| `seperated` | Set to `true` to return results grouped by service, else merged array |
|
||||||
|
|
||||||
### Supported Services
|
### Supported Services
|
||||||
|
|
||||||
|
@ -65,6 +71,8 @@ GET /:userId
|
||||||
- Equicord
|
- Equicord
|
||||||
- Nekocord
|
- Nekocord
|
||||||
- ReviewDb
|
- ReviewDb
|
||||||
|
- Enmity
|
||||||
|
- Discord ( some )
|
||||||
|
|
||||||
### Example
|
### Example
|
||||||
|
|
||||||
|
@ -72,20 +80,12 @@ GET /:userId
|
||||||
GET /209830981060788225?seperated=true&cache=true&services=equicord
|
GET /209830981060788225?seperated=true&cache=true&services=equicord
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
Run formatting and linting with BiomeJS:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run lint
|
|
||||||
bun run lint:fix
|
|
||||||
```
|
|
||||||
|
|
||||||
## Start the Server
|
## Start the Server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
bun i
|
||||||
bun run start
|
bun run start
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
[MIT](LICENSE)
|
[BSD 3](LICENSE)
|
||||||
|
|
24
biome.json
|
@ -7,7 +7,7 @@
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": true,
|
"ignoreUnknown": true,
|
||||||
"ignore": []
|
"ignore": ["dist"]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
@ -17,11 +17,29 @@
|
||||||
"organizeImports": {
|
"organizeImports": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
"css": {
|
||||||
|
"formatter": {
|
||||||
|
"indentStyle": "tab",
|
||||||
|
"lineEnding": "lf"
|
||||||
|
}
|
||||||
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true
|
"recommended": true,
|
||||||
}
|
"correctness": {
|
||||||
|
"noUnusedImports": "error",
|
||||||
|
"noUnusedVariables": "error"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"noConsole": "error"
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"useConst": "error",
|
||||||
|
"noVar": "error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ignore": ["types"]
|
||||||
},
|
},
|
||||||
"javascript": {
|
"javascript": {
|
||||||
"formatter": {
|
"formatter": {
|
||||||
|
|
146
config/constants.ts
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
const discordBadges = {
|
||||||
|
// User badges
|
||||||
|
STAFF: 1 << 0,
|
||||||
|
PARTNER: 1 << 1,
|
||||||
|
HYPESQUAD: 1 << 2,
|
||||||
|
BUG_HUNTER_LEVEL_1: 1 << 3,
|
||||||
|
HYPESQUAD_ONLINE_HOUSE_1: 1 << 6,
|
||||||
|
HYPESQUAD_ONLINE_HOUSE_2: 1 << 7,
|
||||||
|
HYPESQUAD_ONLINE_HOUSE_3: 1 << 8,
|
||||||
|
PREMIUM_EARLY_SUPPORTER: 1 << 9,
|
||||||
|
TEAM_USER: 1 << 10,
|
||||||
|
SYSTEM: 1 << 12,
|
||||||
|
BUG_HUNTER_LEVEL_2: 1 << 14,
|
||||||
|
VERIFIED_DEVELOPER: 1 << 17,
|
||||||
|
CERTIFIED_MODERATOR: 1 << 18,
|
||||||
|
SPAMMER: 1 << 20,
|
||||||
|
ACTIVE_DEVELOPER: 1 << 22,
|
||||||
|
|
||||||
|
// Bot badges
|
||||||
|
VERIFIED_BOT: 1 << 16,
|
||||||
|
BOT_HTTP_INTERACTIONS: 1 << 19,
|
||||||
|
SUPPORTS_COMMANDS: 1 << 23,
|
||||||
|
USES_AUTOMOD: 1 << 24,
|
||||||
|
};
|
||||||
|
|
||||||
|
const discordBadgeDetails = {
|
||||||
|
HYPESQUAD: {
|
||||||
|
tooltip: "HypeSquad Events",
|
||||||
|
icon: "/public/badges/discord/HYPESQUAD.svg",
|
||||||
|
},
|
||||||
|
HYPESQUAD_ONLINE_HOUSE_1: {
|
||||||
|
tooltip: "HypeSquad Bravery",
|
||||||
|
icon: "/public/badges/discord/HYPESQUAD_ONLINE_HOUSE_1.svg",
|
||||||
|
},
|
||||||
|
HYPESQUAD_ONLINE_HOUSE_2: {
|
||||||
|
tooltip: "HypeSquad Brilliance",
|
||||||
|
icon: "/public/badges/discord/HYPESQUAD_ONLINE_HOUSE_2.svg",
|
||||||
|
},
|
||||||
|
HYPESQUAD_ONLINE_HOUSE_3: {
|
||||||
|
tooltip: "HypeSquad Balance",
|
||||||
|
icon: "/public/badges/discord/HYPESQUAD_ONLINE_HOUSE_3.svg",
|
||||||
|
},
|
||||||
|
|
||||||
|
STAFF: {
|
||||||
|
tooltip: "Discord Staff",
|
||||||
|
icon: "/public/badges/discord/STAFF.svg",
|
||||||
|
},
|
||||||
|
PARTNER: {
|
||||||
|
tooltip: "Discord Partner",
|
||||||
|
icon: "/public/badges/discord/PARTNER.svg",
|
||||||
|
},
|
||||||
|
CERTIFIED_MODERATOR: {
|
||||||
|
tooltip: "Certified Moderator",
|
||||||
|
icon: "/public/badges/discord/CERTIFIED_MODERATOR.svg",
|
||||||
|
},
|
||||||
|
|
||||||
|
VERIFIED_DEVELOPER: {
|
||||||
|
tooltip: "Verified Bot Developer",
|
||||||
|
icon: "/public/badges/discord/VERIFIED_DEVELOPER.svg",
|
||||||
|
},
|
||||||
|
ACTIVE_DEVELOPER: {
|
||||||
|
tooltip: "Active Developer",
|
||||||
|
icon: "/public/badges/discord/ACTIVE_DEVELOPER.svg",
|
||||||
|
},
|
||||||
|
|
||||||
|
PREMIUM_EARLY_SUPPORTER: {
|
||||||
|
tooltip: "Premium Early Supporter",
|
||||||
|
icon: "/public/badges/discord/PREMIUM_EARLY_SUPPORTER.svg",
|
||||||
|
},
|
||||||
|
|
||||||
|
BUG_HUNTER_LEVEL_1: {
|
||||||
|
tooltip: "Bug Hunter (Level 1)",
|
||||||
|
icon: "/public/badges/discord/BUG_HUNTER_LEVEL_1.svg",
|
||||||
|
},
|
||||||
|
BUG_HUNTER_LEVEL_2: {
|
||||||
|
tooltip: "Bug Hunter (Level 2)",
|
||||||
|
icon: "/public/badges/discord/BUG_HUNTER_LEVEL_2.svg",
|
||||||
|
},
|
||||||
|
|
||||||
|
SUPPORTS_COMMANDS: {
|
||||||
|
tooltip: "Supports Commands",
|
||||||
|
icon: "/public/badges/discord/SUPPORTS_COMMANDS.svg",
|
||||||
|
},
|
||||||
|
USES_AUTOMOD: {
|
||||||
|
tooltip: "Uses AutoMod",
|
||||||
|
icon: "/public/badges/discord/USES_AUTOMOD.svg",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const badgeServices: BadgeService[] = [
|
||||||
|
{
|
||||||
|
service: "Vencord",
|
||||||
|
url: "https://badges.vencord.dev/badges.json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
service: "Equicord", // Ekwekord ! WOOP
|
||||||
|
url: "https://raw.githubusercontent.com/Equicord/Equibored/refs/heads/main/badges.json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
service: "Nekocord",
|
||||||
|
url: "https://nekocord.dev/assets/badges.json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
service: "ReviewDb",
|
||||||
|
url: "https://manti.vendicated.dev/api/reviewdb/badges",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
service: "Enmity",
|
||||||
|
url: (userId: string) => ({
|
||||||
|
user: `https://raw.githubusercontent.com/enmity-mod/badges/main/${userId}.json`,
|
||||||
|
badge: (id: string) =>
|
||||||
|
`https://raw.githubusercontent.com/enmity-mod/badges/main/data/${id}.json`,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
service: "Discord",
|
||||||
|
url: (userId: string) => `https://discord.com/api/v10/users/${userId}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const vencordEquicordContributorUrl =
|
||||||
|
"https://raw.githubusercontent.com/Equicord/Equibored/refs/heads/main/plugins.json";
|
||||||
|
|
||||||
|
function getServiceDescription(service: string): string {
|
||||||
|
const descriptions: Record<string, string> = {
|
||||||
|
Vencord: "Custom badges from Vencord Discord client",
|
||||||
|
Equicord: "Custom badges from Equicord Discord client",
|
||||||
|
Nekocord: "Custom badges from Nekocord Discord client",
|
||||||
|
ReviewDb: "Badges from ReviewDB service",
|
||||||
|
Enmity: "Custom badges from Enmity mobile Discord client",
|
||||||
|
Discord: "Official Discord badges (staff, partner, hypesquad, etc.)",
|
||||||
|
};
|
||||||
|
|
||||||
|
return descriptions[service] || "Custom badge service";
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitUrl = "https://git.creations.works/creations/badgeAPI";
|
||||||
|
|
||||||
|
export {
|
||||||
|
badgeServices,
|
||||||
|
discordBadges,
|
||||||
|
discordBadgeDetails,
|
||||||
|
vencordEquicordContributorUrl,
|
||||||
|
getServiceDescription,
|
||||||
|
gitUrl,
|
||||||
|
};
|
|
@ -1,38 +0,0 @@
|
||||||
export const environment: Environment = {
|
|
||||||
port: Number.parseInt(process.env.PORT || "8080", 10),
|
|
||||||
host: process.env.HOST || "0.0.0.0",
|
|
||||||
development:
|
|
||||||
process.env.NODE_ENV === "development" || process.argv.includes("--dev"),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const redisTtl: number = process.env.REDIS_TTL
|
|
||||||
? Number.parseInt(process.env.REDIS_TTL, 10)
|
|
||||||
: 60 * 60 * 1; // 1 hour
|
|
||||||
|
|
||||||
// not sure the point ?
|
|
||||||
// function getClientModBadgesUrl(userId: string): string {
|
|
||||||
// return `https://cdn.jsdelivr.net/gh/Equicord/ClientModBadges-API@main/users/${userId}.json`;
|
|
||||||
// }
|
|
||||||
|
|
||||||
export const badgeServices: badgeURLMap[] = [
|
|
||||||
{
|
|
||||||
service: "Vencord",
|
|
||||||
url: "https://badges.vencord.dev/badges.json",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
service: "Equicord", // Ekwekord ! WOOP
|
|
||||||
url: "https://raw.githubusercontent.com/Equicord/Equibored/refs/heads/main/badges.json",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
service: "Nekocord",
|
|
||||||
url: "https://nekocord.dev/assets/badges.json",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
service: "ReviewDb",
|
|
||||||
url: "https://manti.vendicated.dev/api/reviewdb/badges",
|
|
||||||
},
|
|
||||||
// {
|
|
||||||
// service: "ClientMods",
|
|
||||||
// url: getClientModBadgesUrl,
|
|
||||||
// }
|
|
||||||
];
|
|
45
config/index.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import { echo } from "@atums/echo";
|
||||||
|
|
||||||
|
const environment: Environment = {
|
||||||
|
port: Number.parseInt(process.env.PORT || "8080", 10),
|
||||||
|
host: process.env.HOST || "0.0.0.0",
|
||||||
|
development:
|
||||||
|
process.env.NODE_ENV === "development" || process.argv.includes("--dev"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const redisTtl: number = process.env.REDIS_TTL
|
||||||
|
? Number.parseInt(process.env.REDIS_TTL, 10)
|
||||||
|
: 60 * 60 * 1; // 1 hour
|
||||||
|
|
||||||
|
const badgeFetchInterval: number = process.env.BADGE_FETCH_INTERVAL
|
||||||
|
? Number.parseInt(process.env.BADGE_FETCH_INTERVAL, 10)
|
||||||
|
: 60 * 60 * 1000; // 1 hour
|
||||||
|
|
||||||
|
const botToken: string | undefined = process.env.DISCORD_TOKEN;
|
||||||
|
|
||||||
|
function verifyRequiredVariables(): void {
|
||||||
|
const requiredVariables = ["HOST", "PORT", "DISCORD_TOKEN"];
|
||||||
|
|
||||||
|
let hasError = false;
|
||||||
|
|
||||||
|
for (const key of requiredVariables) {
|
||||||
|
const value = process.env[key];
|
||||||
|
if (value === undefined || value.trim() === "") {
|
||||||
|
echo.error(`Missing or empty environment variable: ${key}`);
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export * from "@config/constants";
|
||||||
|
export {
|
||||||
|
environment,
|
||||||
|
redisTtl,
|
||||||
|
badgeFetchInterval,
|
||||||
|
botToken,
|
||||||
|
verifyRequiredVariables,
|
||||||
|
};
|
39
logger.json
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
{
|
||||||
|
"directory": "logs",
|
||||||
|
"level": "info",
|
||||||
|
"disableFile": false,
|
||||||
|
|
||||||
|
"rotate": true,
|
||||||
|
"maxFiles": 3,
|
||||||
|
|
||||||
|
"console": true,
|
||||||
|
"consoleColor": true,
|
||||||
|
|
||||||
|
"dateFormat": "yyyy-MM-dd HH:mm:ss.SSS",
|
||||||
|
"timezone": "local",
|
||||||
|
|
||||||
|
"silent": false,
|
||||||
|
|
||||||
|
"pattern": "{color:gray}{pretty-timestamp}{reset} {color:levelColor}[{level-name}]{reset} {color:gray}({reset}{file-name}:{color:blue}{line}{reset}:{color:blue}{column}{color:gray}){reset} {data}",
|
||||||
|
"levelColor": {
|
||||||
|
"debug": "blue",
|
||||||
|
"info": "green",
|
||||||
|
"warn": "yellow",
|
||||||
|
"error": "red",
|
||||||
|
"fatal": "red"
|
||||||
|
},
|
||||||
|
|
||||||
|
"customPattern": "{color:gray}{pretty-timestamp}{reset} {color:tagColor}[{tag}]{reset} {color:contextColor}({context}){reset} {data}",
|
||||||
|
"customColors": {
|
||||||
|
"GET": "green",
|
||||||
|
"POST": "blue",
|
||||||
|
"PUT": "yellow",
|
||||||
|
"DELETE": "red",
|
||||||
|
"PATCH": "cyan",
|
||||||
|
"HEAD": "magenta",
|
||||||
|
"OPTIONS": "white",
|
||||||
|
"TRACE": "gray"
|
||||||
|
},
|
||||||
|
|
||||||
|
"prettyPrint": true
|
||||||
|
}
|
11
package.json
|
@ -7,18 +7,13 @@
|
||||||
"dev": "bun run --hot src/index.ts --dev",
|
"dev": "bun run --hot src/index.ts --dev",
|
||||||
"lint": "bunx biome check",
|
"lint": "bunx biome check",
|
||||||
"lint:fix": "bunx biome check --fix",
|
"lint:fix": "bunx biome check --fix",
|
||||||
"cleanup": "rm -rf logs node_modules bun.lockdb"
|
"cleanup": "rm -rf logs node_modules bun.lock"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.2.9",
|
"@types/bun": "latest",
|
||||||
"@types/ejs": "^3.1.5",
|
|
||||||
"globals": "^16.0.0",
|
|
||||||
"@biomejs/biome": "^1.9.4"
|
"@biomejs/biome": "^1.9.4"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
|
||||||
"typescript": "^5.8.3"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ejs": "^3.1.10"
|
"@atums/echo": "latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
3
public/badges/discord/ACTIVE_DEVELOPER.svg
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="140" height="140" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M6.47213 4L4 6.47213V17.5279L6.47217 20H17.5278L20 17.5279V6.47213L17.5279 4H6.47213ZM10.8582 16.4255H8.64551C8.64551 14.5952 7.1567 13.1064 5.32642 13.1064V10.8936C7.1567 10.8936 8.64551 9.40483 8.64551 7.57454H10.8582C10.8582 9.39042 9.96684 10.9908 8.61129 12C9.96684 13.0093 10.8582 14.6096 10.8582 16.4255ZM18.6667 13.1064C16.8364 13.1064 15.3476 14.5952 15.3476 16.4255H13.1348C13.1348 14.6096 14.0263 13.0093 15.3818 12C14.0263 10.9908 13.1348 9.39042 13.1348 7.57454H15.3476C15.3476 9.40483 16.8364 10.8936 18.6667 10.8936V13.1064V13.1064Z" fill="#2EA967"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 722 B |
1
public/badges/discord/BUG_HUNTER_LEVEL_1.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" height="140" viewBox="0 0 24 24" width="140"><path d="m16.5822 2.63812s7.6721 5.23623 4.7567 12.58868c-2.9154 7.3525-8.7142 5.313-6.5469 3.1648 2.1674-2.1482-2.5573-3.6059-5.58143-6.3935l7.36523-9.35998" fill="#3ba55c"/><path d="m16.1155 9.83717c-1.6175 2.05873-3.9 3.08803-5.6646 2.71723l-6.15684 7.8447c-.10362.1324-.23231.243-.37871.3256-.1464.0825-.30764.1354-.47451.1556-.16686.0202-.33606.0073-.49793-.038-.16187-.0452-.31322-.122-.44541-.2258-.13374-.1032-.2457-.2319-.32942-.3786s-.13754-.3086-.15834-.4762c-.02081-.1677-.00819-.3378.03712-.5005s.12242-.3149.22687-.4476l6.12492-7.832c-.81197-1.62394-.36443-4.11099 1.27869-6.18886 2.03946-2.58295 5.11476-3.54836 6.89856-2.15459 1.7837 1.39377 1.5664 4.61607-.4604 7.19902z" fill="#b4e1cd"/></svg>
|
After Width: | Height: | Size: 839 B |
1
public/badges/discord/BUG_HUNTER_LEVEL_2.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="140" viewBox="0 0 24 24" width="140"><mask id="a" height="19" maskUnits="userSpaceOnUse" width="16" x="2" y="2"><path d="m16.1438 9.84735c-1.6048 2.04975-3.9088 3.08265-5.7044 2.70125l-6.14926 7.8813c-.44491.572-1.22351.6356-1.79554.1907-.57203-.445-.63558-1.2235-.25423-1.7956l6.1493-7.8177c-.82626-1.60486-.38135-4.09954 1.28707-6.21286 2.04976-2.57413 5.11646-3.52751 6.91196-2.19278 1.7956 1.33473 1.5413 4.6239-.4449 7.24569z" fill="#ffd56c"/></mask><path d="m16.5888 2.60168s7.6906 5.25949 4.7351 12.63232c-2.9555 7.3728-8.7235 5.323-6.5307 3.1461s-2.5582-3.591-5.57726-6.4194z" fill="#ffeac0"/><path d="m16.1438 9.84735c-1.6048 2.04975-3.9088 3.08265-5.7044 2.70125l-6.14926 7.8813c-.44491.572-1.22351.6356-1.79554.1907-.57203-.445-.63558-1.2235-.25423-1.7956l6.1493-7.8177c-.82626-1.60486-.38135-4.09954 1.28707-6.21286 2.04976-2.57413 5.11646-3.52751 6.91196-2.19278 1.7956 1.33473 1.5413 4.6239-.4449 7.24569z" fill="#ffd56c"/><g fill="#fff" mask="url(#a)"><path d="m13.0389-1.26782.7405.09754-3.1567 23.96118-.74043-.0976z"/><path d="m14.2822-1.51801 1.6226.21377-3.1566 23.96114-1.6226-.2137z"/></g></svg>
|
After Width: | Height: | Size: 1.2 KiB |
1
public/badges/discord/CERTIFIED_MODERATOR.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" height="140" viewBox="0 0 24 24" width="140"><path d="m17.2719 3h-9.54383c-.14912 1.9386-1.78947 3.42982-3.72807 3.42982v.89474c0 4.39914 2.08772 8.50004 5.74123 11.40794l2.75877 2.1622 2.7588-2.1622c3.6535-2.8334 5.7412-7.0088 5.7412-11.40794v-.89474c-1.9386 0-3.5044-1.49122-3.7281-3.42982zm-6.4868 12.8991c-2.23685-1.7895-3.57896-4.3245-3.57896-7.08331v-.52193c1.19298 0 2.23684-.89474 2.3114-2.08772h2.98246v11.10966z" fill="#FC964B"/></svg>
|
After Width: | Height: | Size: 528 B |
1
public/badges/discord/HYPESQUAD.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" height="140" viewBox="0 0 24 24" width="140"><g fill="#fbb848"><path d="m21.5912 6.84349-7.8694 5.16551c-.1351.088-.2444.2103-.317.3543l-1.1997 2.4056c-.0174.0399-.0461.0739-.0825.0977-.0364.0239-.079.0366-.1226.0366s-.0862-.0127-.1226-.0366c-.0364-.0238-.0651-.0578-.0825-.0977l-1.1997-2.4056c-.0726-.144-.1819-.2663-.317-.3543l-7.86944-5.16551c-.03957-.04698-.09618-.07632-.15738-.08157-.0612-.00524-.12198.01404-.16896.0536-.04698.03957-.07633.09618-.08157.15738-.00525.0612.01403.12198.0536.16896l3.28825 6.39624c.01598.0335.02385.0703.02297.1074s-.01049.0734-.02804.1061c-.01756.0327-.04257.0608-.07301.082-.03043.0212-.06544.035-.10219.0402h-1.97668c-.04881-.0005-.0965.0146-.13617.043-.03967.0285-.06926.0688-.08449.1152s-.0153.0964-.00022.1428c.01509.0464.04455.0869.08413.1154l8.8142 6.3155c.0403.0275.088.0422.1368.0422s.0965-.0147.1368-.0422l8.8142-6.3155c.0396-.0285.069-.069.0841-.1154s.015-.0964-.0002-.1428-.0448-.0867-.0845-.1152c-.0396-.0284-.0873-.0435-.1362-.043h-1.9766c-.0389-.0015-.0769-.0126-.1105-.0323-.0335-.0197-.0617-.0474-.082-.0806s-.0321-.071-.0343-.1098c-.0022-.0389.0052-.0777.0216-.113l3.3132-6.39624c.0395-.04698.0588-.10776.0536-.16896-.0053-.0612-.0346-.11781-.0816-.15738-.047-.03956-.1078-.05884-.169-.0536-.0612.00525-.1178.03459-.1574.08157z"/><path d="m12.1741 2.10696.8081 1.64723c.0143.02721.0346.05084.0594.06913.0247.01829.0533.03078.0835.03654l1.8213.26107c.0356.00524.0691.02036.0966.04366s.0479.05383.0589.08814.0122.07102.0034.10595c-.0089.03494-.0273.06671-.0532.0917l-1.3178 1.28049c-.0213.02203-.0373.04854-.047.07758s-.0127.05988-.009.09025l.3108 1.80885c.0069.03487.0036.07096-.0094.10404-.013.03307-.0351.06174-.0639.08264-.0287.0209-.0628.03315-.0983.03532-.0354.00217-.0708-.00584-.1019-.02309l-1.6285-.85159c-.0265-.01527-.0565-.02331-.0871-.02331-.0305 0-.0605.00804-.087.02331l-1.6286.85159c-.031.01725-.0664.02526-.1019.02309-.0354-.00217-.0695-.01442-.0983-.03532-.0287-.0209-.0509-.04957-.0639-.08264-.0129-.03308-.0162-.06917-.0094-.10404l.3108-1.80885c.0038-.03037.0008-.06121-.0089-.09025s-.0258-.05555-.047-.07758l-1.31781-1.28049c-.02595-.02499-.04438-.05676-.05318-.0917-.00881-.03493-.00764-.07164.00336-.10595s.03141-.06484.05889-.08814c.02749-.0233.06095-.03842.0966-.04366l1.82124-.25485c.0303-.00576.0588-.01825.0836-.03654.0247-.01829.045-.04192.0594-.06913l.8081-1.64723c.015-.03321.0392-.06147.0696-.08149.0305-.02003.066-.03101.1025-.03166.0364-.00065.0723.00905.1035.02798.0311.01893.0563.0463.0725.07895z"/></g></svg>
|
After Width: | Height: | Size: 2.5 KiB |
1
public/badges/discord/HYPESQUAD_ONLINE_HOUSE_1.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" height="140" viewBox="0 0 24 24" width="140"><path clip-rule="evenodd" d="m5.01502 4h13.97008c.1187 0 .215.09992.215.22305v9.97865c0 .0697-.0312.1343-.0837.1767l-6.985 5.5752c-.0389.0313-.0847.0464-.1314.0464-.0466 0-.0924-.0151-.1313-.0464l-6.985-5.5752c-.05252-.0424-.08365-.107-.08365-.1767v-9.97865c0-.12313.0963-.22305.21497-.22305zm7.82148 7.0972 4.1275-2.71296c.1039-.06863.2299.04542.1725.15644l-1.7114 3.36192c-.0403.0807.0182.1756.1079.1756h1.0246c.118 0 .1664.1504.0706.219l-4.6267 3.3175c-.0414.0303-.0978.0303-.1402 0l-4.6267-3.3175c-.0948-.0686-.04639-.219.07059-.219h1.02356c.09076 0 .14925-.0949.10791-.1756l-1.71132-3.36293c-.05648-.11001.06958-.22305.17345-.15543l4.12851 2.71296c.0716.0474.1291.112.1674.1887l.6293 1.2636c.0444.0888.1714.0888.2158 0l.6293-1.2636c.0383-.0767.0958-.1423.1674-.1887z" fill="#9c84ef" fill-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 943 B |
1
public/badges/discord/HYPESQUAD_ONLINE_HOUSE_2.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" height="140" viewBox="0 0 24 24" width="140"><path clip-rule="evenodd" d="m12 20c4.4183 0 8-3.5817 8-8 0-4.41828-3.5817-8-8-8-4.41828 0-8 3.58172-8 8 0 4.4183 3.58172 8 8 8zm.7921-8.275 3.6146-2.3738c.0909-.05916.2013.03974.151.136l-1.4986 2.9416c-.0354.0707.0158.1537.0944.1537h.8973c.1033 0 .1457.1315.0618.1916l-4.0517 2.9027c-.0362.0265-.0856.0265-.1227 0l-4.05168-2.9027c-.08301-.0601-.04062-.1916.06182-.1916h.89634c.07948 0 .1307-.083.09449-.1537l-1.49862-2.9416c-.04945-.09626.06094-.19516.1519-.136l3.61545 2.3738c.0627.0415.113.098.1465.1651l.5511 1.1057c.0389.0777.1501.0777.189 0l.551-1.1057c.0336-.0671.0839-.1245.1466-.1651z" fill="#f47b67" fill-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 765 B |
1
public/badges/discord/HYPESQUAD_ONLINE_HOUSE_3.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" height="140" viewBox="0 0 24 24" width="140"><path clip-rule="evenodd" d="m11.8622 4.05696c.076-.07595.1996-.07595.2756 0l7.8048 7.80474c.0371.0362.0574.0865.0574.1377 0 .0513-.0212.1016-.0574.1378l-7.8048 7.8047c-.038.038-.0883.0574-.1378.0574s-.0998-.0194-.1378-.0574l-7.8048-7.8047c-.03709-.0362-.0574-.0857-.0574-.1378s.02031-.1015.0574-.1377zm.9299 8.29474 3.6146-2.37377c.0909-.05917.2013.03977.151.13597l-1.4986 2.9416c-.0354.0707.0158.1537.0944.1537h.8973c.1033 0 .1457.1316.0618.1916l-4.0517 2.9028c-.0362.0265-.0856.0265-.1227 0l-4.05168-2.9028c-.08301-.06-.04062-.1916.06182-.1916h.89634c.07948 0 .1307-.083.09449-.1537l-1.49862-2.9416c-.04945-.0962.06094-.19514.1519-.13597l3.61545 2.37377c.0627.0415.113.098.1465.1651l.5511 1.1057c.0389.0777.1501.0777.189 0l.551-1.1057c.0336-.0671.0839-.1245.1466-.1651z" fill="#45ddc0" fill-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 944 B |
1
public/badges/discord/NITRO.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" height="140" viewBox="0 0 24 24" width="140"><circle cx="15" cy="12" fill="#fff" r="6"/><path clip-rule="evenodd" d="m2.20812 10.124c.42636 0 .7816-.34817.7816-.76611 0-.41793-.35524-.76615-.7816-.76615h-.42635c-.42636 0-.78177.34822-.78177.76615 0 .41794.35541.76611.78177.76611zm16.13038 9.2643c4.0504-1.811 5.7558-6.4083 3.9083-10.23937-1.2791-2.71657-3.9793-4.31859-6.8217-4.45801h-8.02965c-.71065 0-1.20812.55735-1.20812 1.18425 0 .69645.56859 1.18409 1.20812 1.18409h2.06067c.42635 0 .78158.34822.78158.76616 0 .41793-.35523.76632-.78158.76632h-5.04517c-.42635 0-.78176.34822-.78176.76615 0 .41794.35541.76611.78176.76611h3.62404c.42635 0 .78159.3484.78159.7664 0 .4179-.35524.7661-.78159.7661h-2.27402c-.42636 0-.7816.3482-.7816.7662 0 .4179.35524.7663.7816.7663h1.56336c.07112.8359.2843 1.6717.63954 2.4379 1.77654 3.8311 6.46643 5.5028 10.37463 3.7614zm-7.2725-5.1884c-1.0318-2.2025-.0466-4.80794 2.2003-5.81933 2.2469-1.0114 4.9049-.04564 5.9366 2.15683 1.0318 2.2025.0468 4.8079-2.2003 5.8193-2.2469 1.0114-4.9048.0457-5.9366-2.1568z" fill="#4f5d7f" fill-rule="evenodd"/><path d="m16.8142 9.86662 1.4212 2.36838c.0711.1392.0711.2089 0 .3482l-1.4212 2.3683c-.0711.1393-.2131.1393-.2842.1393h-2.7714c-.142 0-.2131-.0697-.2841-.1393l-1.4213-2.3683c-.0709-.1393-.0709-.209 0-.3482l1.4213-2.36838c.071-.13926.2132-.13926.2841-.13926h2.7714c.1422-.06971.2131 0 .2842.13926z" fill="#c5cedd"/></svg>
|
After Width: | Height: | Size: 1.5 KiB |
1
public/badges/discord/PARTNER.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" height="140" viewBox="0 0 24 24" width="140"><g fill="#5865f2"><path d="m16.6033 9.15179-2.4908 1.66051c-.249.2491-.6642.1661-.7472 0-.2491-.2491-.6642-.4151-.9133-.4982-.6642-.166-1.2454 0-1.7435.2491l-.83027.5812-4.64945 2.9889c-.99631.6642-2.2417.4152-2.9059-.6642-.66421-1.0793-.24908-2.2417.74723-2.8228l5.31365-3.65318c1.49447-.83026 3.23804-1.24539 4.89854-.83026 1.4114.24907 2.6568.99631 3.4871 2.15867.249.16605.249.66421-.1661.83026z"/><path d="m22 11.6425c0 .7473-.4152 1.4115-.9963 1.7436l-5.4797 3.5701c-.9964.6642-2.2417.9963-3.4041.9963-.4982 0-.9963 0-1.4114-.166-1.41148-.2491-2.49081-1.1624-3.48712-2.1587-.16606-.1661-.16606-.6642.16605-.7473l2.49077-1.6605c.2491-.249.6642-.166.7472 0 .2491.2491.4982.4152.9133.4982.6642.166 1.2454 0 1.7436-.2491l1.2453-.7472 3.7362-2.4908.4982-.41513c.9963-.6642 2.2417-.41512 2.9059.66423.166.4151.3321.7472.3321 1.1623z"/></g></svg>
|
After Width: | Height: | Size: 973 B |
1
public/badges/discord/PREMIUM_EARLY_SUPPORTER.svg
Normal file
After Width: | Height: | Size: 6.1 KiB |
1
public/badges/discord/STAFF.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" height="140" viewBox="0 0 24 24" width="140"><g fill="#5865f2"><path d="m5.92127 6.03526s.22115-.33086.31286-.47743c.09172-.14657-.23743-.49286-.36514-.60257-.12772-.10971-.32914-.05486-.32914-.05486-1.60715.71229-2.41115 2.17372-2.52086 2.466-.10972.29229.27943.61115.56657.76715.132.072.342-.08743.47143-.20572l.04371-.04457.06772-.06857.00085-.00086 4.37229 4.35517.59743-.5975 1.09801-1.098-4.32173-4.43224z"/><path d="m16.2505 10.6294.2306-.2194 2.0717 2.052c.0146.0129.03.018.0437.018.0395 0 .072-.036.072-.036s2.2937-2.2757 2.3015-2.2834c.0677-.0669 0-.1037 0-.1037l-1.7692-1.78119-.0026.00258-.2425-.23743.1354-.13029.2897.03343-.0548-.384.0728-.07371-.1088-.55372c-.378-.53571-1.4135-1.39371-1.4135-1.39371l-.5417-.09772-.0548.07286-.408-.06086.0394.348.0257.02572-.1209.12171-.6685-.654s-3.8795-2.10686-4.086-2.20457c-.1166-.054-.2023-.09-.2846-.09-.0634 0-.1251.02143-.1963.072-.1646.11571-.0677.34886-.0677.34886l2.412 4.45714.4826.47829-.1509.15085-.0557.05572-.3857-.05315.0591.38229-.1114.11143-.0197-.01972c-.018-.018-.0429-.02742-.0669-.02742s-.048.00942-.0668.02742c-.0369.03686-.0369.09686 0 .13372l.0197.01971-.0532.054-.0137-.01457c-.0188-.018-.0428-.02743-.0668-.02743-.0249 0-.0489.00943-.0669.02743-.0368.03686-.0368.09686 0 .13372l.0146.01457-1.0149 1.02004-.0231-.0232c-.0189-.018-.0429-.0274-.0669-.0274s-.048.0094-.0668.0274c-.0369.0369-.0369.0969 0 .1337l.024.0232-.054.054-.018-.0172c-.018-.0188-.0429-.0283-.066-.0283-.0249 0-.0489.0095-.0677.0283-.036.0369-.036.096 0 .1329l.018.018-.132.1337-.018.1697.0694.0712-.0017.0008-.084.0857-5.47632 5.4755-.07114-.0592-.22714.0326-.12858.1303-.00857-.0086c-.01885-.0189-.04285-.0283-.06685-.0283s-.04886.0094-.06686.0283c-.03686.0369-.03686.096 0 .1329l.01028.0102-.05314.0549-.00514-.0051c-.018-.0189-.04286-.0283-.06686-.0283s-.048.0094-.06686.0283c-.036.0368-.036.096 0 .1328l.006.0069-1.002 1.0191-.02057-.0206c-.01885-.0188-.042-.0274-.06685-.0274-.024 0-.048.0086-.06686.0274-.03686.0369-.03686.0969 0 .1338l.02228.0214-.05314.054-.01628-.0163c-.01886-.018-.04286-.0274-.06772-.0274-.02314 0-.048.0094-.066.0274-.03686.0369-.03686.0969 0 .1337l.01714.018-.07457.0763-.38828-.0694.02914.4337-.12257.1251.10628.5846s.16286.5091.498.8469c.32486.3274.82029.4842.84172.5005l.55971.0977.138-.1354.38572.0626-.06343-.3814.11743-.1149.054.054c.018.018.042.0274.066.0274s.04885-.0094.06685-.0274c.03686-.0377.03686-.0969 0-.1337l-.05314-.0532.05486-.0531.04628.0463c.018.0188.04286.0283.06686.0283s.048-.0095.06686-.0283c.03686-.0369.03686-.096 0-.1329l-.04543-.0463 1.01743-1.0037.04457.0446c.018.0189.04286.0274.06686.0274s.048-.0085.06685-.0274c.036-.0369.036-.0969 0-.1337l-.04371-.0429.054-.054.03771.0377c.018.018.042.0275.066.0275.02486 0 .04886-.0095.06686-.0275.03686-.0368.03686-.0968 0-.1337l-.03686-.0368.114-.1115.04115-.2442-.06086-.0609.00086-.0009.11057-.1097 5.43946-5.4411-.0026-.0052.1063.1098.1706-.0189.1534-.1543.0248.0249c.0189.018.0429.0274.0669.0274s.0489-.0094.0669-.0274c.0368-.0369.0368-.0969 0-.1337l-.0249-.0249.054-.0531.0189.0188c.018.018.042.0274.0668.0274.024 0 .048-.0094.066-.0274.0369-.0368.0369-.0968 0-.1337l-.0188-.0197 1.0165-1.0183.0266.0266c.018.018.042.0274.066.0274.0249 0 .0489-.0094.0669-.0274.0368-.0369.0368-.0969 0-.1337l-.0266-.0266.054-.054.0206.0214c.0188.018.0428.0274.0668.0274s.048-.0094.0669-.0274c.0368-.0377.0368-.0968 0-.1337l-.0206-.0214.1131-.1132.378.0592z"/><path d="m17.0057 16.7793-2.4111-1.8274-.4294-.4423-1.6637 1.6637.4183.3995 1.5711 2.3562 2.1188 2.3203 2.4421-2.2783z"/></g></svg>
|
After Width: | Height: | Size: 3.5 KiB |
1
public/badges/discord/SUPPORTS_COMMANDS.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="140" height="140" ><g fill="#2ea967"><path d="m8.1176653 16.0847263 4.8330812-8.1694527h2.9315882l-4.8330812 8.1694527z"/><path d="m20.4189453 9.4038086v-2.4311524c0-1.9775391-1.0825195-3.1118164-2.9697266-3.1118164h-1.5581055v1.7802734l.9594727-.0014648c.8540039 0 1.34375.5683594 1.34375 1.5585938v2.3969727c0 .8300781.1806641 1.8422852 1.5893555 2.3100586l.2856445.0947265-.2856445.0947266c-1.4086914.4677734-1.5893555 1.4799804-1.5893555 2.3100586v2.3964844c0 .9907227-.4897461 1.559082-1.34375 1.559082l-.9594727-.0014648v1.7802734h1.5581055c1.887207 0 2.9697266-1.1342773 2.9697266-3.1118164v-2.4316406c0-1.2583008.3432617-1.6264648 1.5810547-1.6445312v-1.9023438c-1.237793-.0180665-1.5810547-.3862305-1.5810547-1.6450196z"/><path d="m5.8061523 7.1982422c0-.9760742.5024414-1.5585938 1.3432617-1.5585938l.9594727.0014648v-1.7802734h-1.5576172c-1.887207 0-2.9697266 1.1342773-2.9697266 3.1118164v2.4311523c0 1.2587891-.3432617 1.6269531-1.581543 1.6450195v1.9023438c1.2382812.0180664 1.581543.3862305 1.581543 1.6445312v2.4316406c0 1.9775391 1.0825195 3.1118164 2.9697266 3.1118164h1.5576172v-1.7802734l-.9594727.0014648c-.8408203 0-1.3432617-.5830078-1.3432617-1.559082v-2.3964844c0-.8300781-.1806641-1.8422852-1.5898438-2.3100586l-.2856444-.0947264.2856445-.0947266c1.4091797-.4677734 1.5898437-1.4799804 1.5898437-2.3100586z"/></g></svg>
|
After Width: | Height: | Size: 1.4 KiB |
19
public/badges/discord/USES_AUTOMOD.svg
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="140" height="140" viewBox="0 0 220 220" fill="none">
|
||||||
|
<path d="M180.722 58.0798C175.506 46.1379 169.055 36.6667 146.132 36.6667H36.8694C29.5944 36.6667 18.7723 41.3596 17.8068 58.0798C15.3012 101.469 16.2781 126.993 20.2049 146.583C24.2831 166.929 44.1677 178.981 64.9184 178.981H187.564C196.322 178.981 205.018 174.782 208.004 166.549C216.319 143.631 206.034 117.876 180.722 58.0798Z" fill="url(#paint0_linear_22_1025)"/>
|
||||||
|
<path d="M90.6762 144.86H191.447C198.128 144.86 200.541 141.52 197.386 130.756C189.471 104.369 179.863 78.5194 168.62 53.369C160.826 36.6667 155.259 36.6667 139.67 36.6667H35.6316C58.7794 36.6667 59.5827 36.6667 70.2622 130.756C73.2315 144.86 77.4999 144.86 90.6762 144.86Z" fill="url(#paint1_linear_22_1025)"/>
|
||||||
|
<path d="M157.91 86.3648C162.376 84.8532 163.891 77.4104 161.295 69.7408C158.699 62.0713 152.975 57.0792 148.509 58.5908C144.043 60.1024 142.527 67.5452 145.123 75.2148C147.719 82.8843 153.444 87.8764 157.91 86.3648Z" fill="#57F287"/>
|
||||||
|
<path d="M91.6027 86.6163C96.1379 84.9924 97.5738 77.4181 94.8098 69.6985C92.0458 61.9789 86.1286 57.0373 81.5933 58.6612C77.058 60.285 75.6221 67.8594 78.3862 75.579C81.1502 83.2985 87.0674 88.2401 91.6027 86.6163Z" fill="#57F287"/>
|
||||||
|
<path d="M152.846 102.364C153.402 117.024 146.165 127.603 133.916 127.603C121.668 127.603 107.935 117.024 99.7692 102.364H152.846Z" fill="#57F287"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear_22_1025" x1="73.3332" y1="220" x2="145.088" y2="55.3344" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0.180208" stop-color="#3442D9"/>
|
||||||
|
<stop offset="0.747916" stop-color="#737FFF"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint1_linear_22_1025" x1="103.296" y1="47.3376" x2="125.974" y2="142.151" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop/>
|
||||||
|
<stop offset="0.11" stop-color="#050409"/>
|
||||||
|
<stop offset="0.7" stop-color="#1B1934"/>
|
||||||
|
<stop offset="1" stop-color="#242145"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.9 KiB |
1
public/badges/discord/VERIFIED_DEVELOPER.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" height="140" viewBox="0 0 24 24" width="140"><path d="m21.58 11.4-4.28-7.39-.35-.6h-9.91l-.35.6-4.27 7.39-.35.6.35.6 4.27 7.39.35.6h9.92l.35-.6 4.28-7.39.35-.6zm-13.07-1.03-1.63 1.63 1.63 1.63v2.73l-4.36-4.36 4.37-4.37v2.74zm3.12 6.93-2.04-.63 3.1-9.98 2.04.64zm3.86-.93v-2.73l1.63-1.64-1.63-1.63v-2.74l4.36 4.37z" fill="#3e70dd"/></svg>
|
After Width: | Height: | Size: 420 B |
|
@ -1,111 +0,0 @@
|
||||||
import { badgeServices, redisTtl } from "@config/environment";
|
|
||||||
import { fetch, redis } from "bun";
|
|
||||||
|
|
||||||
export async function fetchBadges(
|
|
||||||
userId: string,
|
|
||||||
services: string[],
|
|
||||||
options?: FetchBadgesOptions,
|
|
||||||
): Promise<BadgeResult> {
|
|
||||||
const { nocache = false, separated = false } = options ?? {};
|
|
||||||
const results: Record<string, Badge[]> = {};
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
services.map(async (service) => {
|
|
||||||
const entry = badgeServices.find(
|
|
||||||
(s) => s.service.toLowerCase() === service.toLowerCase(),
|
|
||||||
);
|
|
||||||
if (!entry) return;
|
|
||||||
|
|
||||||
const serviceKey = service.toLowerCase();
|
|
||||||
const cacheKey = `badges:${serviceKey}:${userId}`;
|
|
||||||
|
|
||||||
if (!nocache) {
|
|
||||||
const cached = await redis.get(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
try {
|
|
||||||
const parsed: Badge[] = JSON.parse(cached);
|
|
||||||
results[serviceKey] = parsed;
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
// corrupted cache, proceed with fetch :p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let url: string;
|
|
||||||
if (typeof entry.url === "function") {
|
|
||||||
url = entry.url(userId);
|
|
||||||
} else {
|
|
||||||
url = entry.url;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) return;
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
const result: Badge[] = [];
|
|
||||||
|
|
||||||
switch (serviceKey) {
|
|
||||||
case "vencord":
|
|
||||||
case "equicord": {
|
|
||||||
const userBadges = data[userId];
|
|
||||||
if (Array.isArray(userBadges)) {
|
|
||||||
for (const b of userBadges) {
|
|
||||||
result.push({
|
|
||||||
tooltip: b.tooltip,
|
|
||||||
badge: b.badge,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "nekocord": {
|
|
||||||
const userBadgeIds = data.users?.[userId]?.badges;
|
|
||||||
if (Array.isArray(userBadgeIds)) {
|
|
||||||
for (const id of userBadgeIds) {
|
|
||||||
const badgeInfo = data.badges?.[id];
|
|
||||||
if (badgeInfo) {
|
|
||||||
result.push({
|
|
||||||
tooltip: badgeInfo.name,
|
|
||||||
badge: badgeInfo.image,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "reviewdb": {
|
|
||||||
for (const b of data) {
|
|
||||||
if (b.discordID === userId) {
|
|
||||||
result.push({
|
|
||||||
tooltip: b.name,
|
|
||||||
badge: b.icon,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.length > 0) {
|
|
||||||
results[serviceKey] = result;
|
|
||||||
if (!nocache) {
|
|
||||||
await redis.set(cacheKey, JSON.stringify(result));
|
|
||||||
await redis.expire(cacheKey, redisTtl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (separated) return results;
|
|
||||||
|
|
||||||
const combined: Badge[] = [];
|
|
||||||
for (const group of Object.values(results)) {
|
|
||||||
combined.push(...group);
|
|
||||||
}
|
|
||||||
return combined;
|
|
||||||
}
|
|
|
@ -1,19 +0,0 @@
|
||||||
export function timestampToReadable(timestamp?: number): string {
|
|
||||||
const date: Date =
|
|
||||||
timestamp && !Number.isNaN(timestamp) ? new Date(timestamp) : new Date();
|
|
||||||
if (Number.isNaN(date.getTime())) return "Invalid Date";
|
|
||||||
return date.toISOString().replace("T", " ").replace("Z", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateID(id: string): boolean {
|
|
||||||
if (!id) return false;
|
|
||||||
|
|
||||||
return /^\d{17,20}$/.test(id.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseServices(input: string): string[] {
|
|
||||||
return input
|
|
||||||
.split(/[\s,]+/)
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
|
@ -1,205 +0,0 @@
|
||||||
import type { Stats } from "node:fs";
|
|
||||||
import {
|
|
||||||
type WriteStream,
|
|
||||||
createWriteStream,
|
|
||||||
existsSync,
|
|
||||||
mkdirSync,
|
|
||||||
statSync,
|
|
||||||
} from "node:fs";
|
|
||||||
import { EOL } from "node:os";
|
|
||||||
import { basename, join } from "node:path";
|
|
||||||
import { environment } from "@config/environment";
|
|
||||||
import { timestampToReadable } from "@helpers/char";
|
|
||||||
|
|
||||||
class Logger {
|
|
||||||
private static instance: Logger;
|
|
||||||
private static log: string = join(__dirname, "../../logs");
|
|
||||||
|
|
||||||
public static getInstance(): Logger {
|
|
||||||
if (!Logger.instance) {
|
|
||||||
Logger.instance = new Logger();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Logger.instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
private writeToLog(logMessage: string): void {
|
|
||||||
if (environment.development) return;
|
|
||||||
|
|
||||||
const date: Date = new Date();
|
|
||||||
const logDir: string = Logger.log;
|
|
||||||
const logFile: string = join(
|
|
||||||
logDir,
|
|
||||||
`${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}.log`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!existsSync(logDir)) {
|
|
||||||
mkdirSync(logDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
let addSeparator = false;
|
|
||||||
|
|
||||||
if (existsSync(logFile)) {
|
|
||||||
const fileStats: Stats = statSync(logFile);
|
|
||||||
if (fileStats.size > 0) {
|
|
||||||
const lastModified: Date = new Date(fileStats.mtime);
|
|
||||||
if (
|
|
||||||
lastModified.getFullYear() === date.getFullYear() &&
|
|
||||||
lastModified.getMonth() === date.getMonth() &&
|
|
||||||
lastModified.getDate() === date.getDate() &&
|
|
||||||
lastModified.getHours() !== date.getHours()
|
|
||||||
) {
|
|
||||||
addSeparator = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stream: WriteStream = createWriteStream(logFile, { flags: "a" });
|
|
||||||
|
|
||||||
if (addSeparator) {
|
|
||||||
stream.write(`${EOL}${date.toISOString()}${EOL}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
stream.write(`${logMessage}${EOL}`);
|
|
||||||
stream.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractFileName(stack: string): string {
|
|
||||||
const stackLines: string[] = stack.split("\n");
|
|
||||||
let callerFile = "";
|
|
||||||
|
|
||||||
for (let i = 2; i < stackLines.length; i++) {
|
|
||||||
const line: string = stackLines[i].trim();
|
|
||||||
if (line && !line.includes("Logger.") && line.includes("(")) {
|
|
||||||
callerFile = line.split("(")[1]?.split(")")[0] || "";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return basename(callerFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getCallerInfo(stack: unknown): {
|
|
||||||
filename: string;
|
|
||||||
timestamp: string;
|
|
||||||
} {
|
|
||||||
const filename: string =
|
|
||||||
typeof stack === "string" ? this.extractFileName(stack) : "unknown";
|
|
||||||
|
|
||||||
const readableTimestamp: string = timestampToReadable();
|
|
||||||
|
|
||||||
return { filename, timestamp: readableTimestamp };
|
|
||||||
}
|
|
||||||
|
|
||||||
public info(message: string | string[], breakLine = false): void {
|
|
||||||
const stack: string = new Error().stack || "";
|
|
||||||
const { filename, timestamp } = this.getCallerInfo(stack);
|
|
||||||
|
|
||||||
const joinedMessage: string = Array.isArray(message)
|
|
||||||
? message.join(" ")
|
|
||||||
: message;
|
|
||||||
|
|
||||||
const logMessageParts: ILogMessageParts = {
|
|
||||||
readableTimestamp: { value: timestamp, color: "90" },
|
|
||||||
level: { value: "[INFO]", color: "32" },
|
|
||||||
filename: { value: `(${filename})`, color: "36" },
|
|
||||||
message: { value: joinedMessage, color: "0" },
|
|
||||||
};
|
|
||||||
|
|
||||||
this.writeToLog(`${timestamp} [INFO] (${filename}) ${joinedMessage}`);
|
|
||||||
this.writeConsoleMessageColored(logMessageParts, breakLine);
|
|
||||||
}
|
|
||||||
|
|
||||||
public warn(message: string | string[], breakLine = false): void {
|
|
||||||
const stack: string = new Error().stack || "";
|
|
||||||
const { filename, timestamp } = this.getCallerInfo(stack);
|
|
||||||
|
|
||||||
const joinedMessage: string = Array.isArray(message)
|
|
||||||
? message.join(" ")
|
|
||||||
: message;
|
|
||||||
|
|
||||||
const logMessageParts: ILogMessageParts = {
|
|
||||||
readableTimestamp: { value: timestamp, color: "90" },
|
|
||||||
level: { value: "[WARN]", color: "33" },
|
|
||||||
filename: { value: `(${filename})`, color: "36" },
|
|
||||||
message: { value: joinedMessage, color: "0" },
|
|
||||||
};
|
|
||||||
|
|
||||||
this.writeToLog(`${timestamp} [WARN] (${filename}) ${joinedMessage}`);
|
|
||||||
this.writeConsoleMessageColored(logMessageParts, breakLine);
|
|
||||||
}
|
|
||||||
|
|
||||||
public error(
|
|
||||||
message: string | Error | (string | Error)[],
|
|
||||||
breakLine = false,
|
|
||||||
): void {
|
|
||||||
const stack: string = new Error().stack || "";
|
|
||||||
const { filename, timestamp } = this.getCallerInfo(stack);
|
|
||||||
|
|
||||||
const messages: (string | Error)[] = Array.isArray(message)
|
|
||||||
? message
|
|
||||||
: [message];
|
|
||||||
const joinedMessage: string = messages
|
|
||||||
.map((msg: string | Error): string =>
|
|
||||||
typeof msg === "string" ? msg : msg.message,
|
|
||||||
)
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
const logMessageParts: ILogMessageParts = {
|
|
||||||
readableTimestamp: { value: timestamp, color: "90" },
|
|
||||||
level: { value: "[ERROR]", color: "31" },
|
|
||||||
filename: { value: `(${filename})`, color: "36" },
|
|
||||||
message: { value: joinedMessage, color: "0" },
|
|
||||||
};
|
|
||||||
|
|
||||||
this.writeToLog(`${timestamp} [ERROR] (${filename}) ${joinedMessage}`);
|
|
||||||
this.writeConsoleMessageColored(logMessageParts, breakLine);
|
|
||||||
}
|
|
||||||
|
|
||||||
public custom(
|
|
||||||
bracketMessage: string,
|
|
||||||
bracketMessage2: string,
|
|
||||||
message: string | string[],
|
|
||||||
color: string,
|
|
||||||
breakLine = false,
|
|
||||||
): void {
|
|
||||||
const stack: string = new Error().stack || "";
|
|
||||||
const { timestamp } = this.getCallerInfo(stack);
|
|
||||||
|
|
||||||
const joinedMessage: string = Array.isArray(message)
|
|
||||||
? message.join(" ")
|
|
||||||
: message;
|
|
||||||
|
|
||||||
const logMessageParts: ILogMessageParts = {
|
|
||||||
readableTimestamp: { value: timestamp, color: "90" },
|
|
||||||
level: { value: bracketMessage, color },
|
|
||||||
filename: { value: `${bracketMessage2}`, color: "36" },
|
|
||||||
message: { value: joinedMessage, color: "0" },
|
|
||||||
};
|
|
||||||
|
|
||||||
this.writeToLog(
|
|
||||||
`${timestamp} ${bracketMessage} (${bracketMessage2}) ${joinedMessage}`,
|
|
||||||
);
|
|
||||||
this.writeConsoleMessageColored(logMessageParts, breakLine);
|
|
||||||
}
|
|
||||||
|
|
||||||
public space(): void {
|
|
||||||
console.log();
|
|
||||||
}
|
|
||||||
|
|
||||||
private writeConsoleMessageColored(
|
|
||||||
logMessageParts: ILogMessageParts,
|
|
||||||
breakLine = false,
|
|
||||||
): void {
|
|
||||||
const logMessage: string = Object.keys(logMessageParts)
|
|
||||||
.map((key: string) => {
|
|
||||||
const part: ILogMessagePart = logMessageParts[key];
|
|
||||||
return `\x1b[${part.color}m${part.value}\x1b[0m`;
|
|
||||||
})
|
|
||||||
.join(" ");
|
|
||||||
console.log(logMessage + (breakLine ? EOL : ""));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const logger: Logger = Logger.getInstance();
|
|
||||||
export { logger };
|
|
33
src/index.ts
|
@ -1,12 +1,37 @@
|
||||||
import { logger } from "@helpers/logger";
|
import { echo } from "@atums/echo";
|
||||||
|
import { verifyRequiredVariables } from "@config";
|
||||||
import { serverHandler } from "@/server";
|
import { badgeCacheManager } from "@lib/badgeCache";
|
||||||
|
import { serverHandler } from "@server";
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
|
verifyRequiredVariables();
|
||||||
|
|
||||||
|
await badgeCacheManager.initialize();
|
||||||
|
|
||||||
|
process.on("SIGINT", async () => {
|
||||||
|
echo.debug("Received SIGINT, shutting down gracefully...");
|
||||||
|
await badgeCacheManager.shutdown();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("SIGTERM", async () => {
|
||||||
|
echo.debug("Received SIGTERM, shutting down gracefully...");
|
||||||
|
await badgeCacheManager.shutdown();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
serverHandler.initialize();
|
serverHandler.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error: Error) => {
|
main().catch((error: Error) => {
|
||||||
logger.error(["Error initializing the server:", error]);
|
echo.error({
|
||||||
|
message: "Error initializing the server",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (process.env.IN_PTERODACTYL === "true") {
|
||||||
|
// biome-ignore lint/suspicious/noConsole: Needed for Pterodactyl to actually know the server started
|
||||||
|
console.log("Server Started");
|
||||||
|
}
|
||||||
|
|
305
src/lib/badgeCache.ts
Normal file
|
@ -0,0 +1,305 @@
|
||||||
|
import { echo } from "@atums/echo";
|
||||||
|
import {
|
||||||
|
badgeFetchInterval,
|
||||||
|
badgeServices,
|
||||||
|
gitUrl,
|
||||||
|
redisTtl,
|
||||||
|
vencordEquicordContributorUrl,
|
||||||
|
} from "@config";
|
||||||
|
import { redis } from "bun";
|
||||||
|
|
||||||
|
class BadgeCacheManager {
|
||||||
|
private updateInterval: Timer | null = null;
|
||||||
|
private readonly CACHE_PREFIX = "badge_service_data:";
|
||||||
|
private readonly CACHE_TIMESTAMP_PREFIX = "badge_cache_timestamp:";
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
echo.debug("Initializing badge cache manager...");
|
||||||
|
|
||||||
|
const needsUpdate = await this.checkIfUpdateNeeded();
|
||||||
|
if (needsUpdate) {
|
||||||
|
await this.updateAllServiceData();
|
||||||
|
} else {
|
||||||
|
echo.debug("Badge cache is still valid, skipping initial update");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateInterval = setInterval(
|
||||||
|
() => this.updateAllServiceData(),
|
||||||
|
badgeFetchInterval,
|
||||||
|
);
|
||||||
|
|
||||||
|
echo.debug("Badge cache manager initialized with 1-hour update interval");
|
||||||
|
}
|
||||||
|
|
||||||
|
async shutdown(): Promise<void> {
|
||||||
|
if (this.updateInterval) {
|
||||||
|
clearInterval(this.updateInterval);
|
||||||
|
this.updateInterval = null;
|
||||||
|
}
|
||||||
|
echo.debug("Badge cache manager shut down");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkIfUpdateNeeded(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const staticServices = ["vencord", "equicord", "nekocord", "reviewdb"];
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const serviceName of staticServices) {
|
||||||
|
const timestampKey = `${this.CACHE_TIMESTAMP_PREFIX}${serviceName}`;
|
||||||
|
const cacheKey = `${this.CACHE_PREFIX}${serviceName}`;
|
||||||
|
|
||||||
|
const [timestamp, data] = await Promise.all([
|
||||||
|
redis.get(timestampKey),
|
||||||
|
redis.get(cacheKey),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!data || !timestamp) {
|
||||||
|
echo.debug(`Cache missing for service: ${serviceName}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastUpdate = Number.parseInt(timestamp, 10);
|
||||||
|
if (now - lastUpdate > badgeFetchInterval) {
|
||||||
|
echo.debug(`Cache expired for service: ${serviceName}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo.debug("All service caches are valid");
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
echo.warn({
|
||||||
|
message: "Failed to check cache validity, forcing update",
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateAllServiceData(): Promise<void> {
|
||||||
|
echo.debug("Updating badge service data...");
|
||||||
|
|
||||||
|
const updatePromises = badgeServices.map(async (service: BadgeService) => {
|
||||||
|
try {
|
||||||
|
await this.updateServiceData(service);
|
||||||
|
} catch (error) {
|
||||||
|
echo.error({
|
||||||
|
message: `Failed to update service data for ${service.service}`,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.allSettled(updatePromises);
|
||||||
|
echo.debug("Badge service data update completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateServiceData(service: BadgeService): Promise<void> {
|
||||||
|
const serviceKey = service.service.toLowerCase();
|
||||||
|
const cacheKey = `${this.CACHE_PREFIX}${serviceKey}`;
|
||||||
|
const timestampKey = `${this.CACHE_TIMESTAMP_PREFIX}${serviceKey}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let data: BadgeServiceData | null = null;
|
||||||
|
|
||||||
|
switch (serviceKey) {
|
||||||
|
case "vencord":
|
||||||
|
case "equicord": {
|
||||||
|
if (typeof service.url === "string") {
|
||||||
|
const res = await fetch(service.url, {
|
||||||
|
headers: {
|
||||||
|
"User-Agent": `BadgeAPI/1.0 ${gitUrl}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
data = (await res.json()) as VencordEquicordData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof vencordEquicordContributorUrl === "string") {
|
||||||
|
const contributorRes = await fetch(vencordEquicordContributorUrl, {
|
||||||
|
headers: {
|
||||||
|
"User-Agent": `BadgeAPI/1.0 ${gitUrl}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (contributorRes.ok) {
|
||||||
|
const pluginData = await contributorRes.json();
|
||||||
|
|
||||||
|
if (Array.isArray(pluginData)) {
|
||||||
|
if (!data) {
|
||||||
|
data = {} as VencordEquicordData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contributors = new Set<string>();
|
||||||
|
|
||||||
|
for (const plugin of pluginData) {
|
||||||
|
if (plugin.authors && Array.isArray(plugin.authors)) {
|
||||||
|
const isEquicordPlugin =
|
||||||
|
plugin.filePath &&
|
||||||
|
typeof plugin.filePath === "string" &&
|
||||||
|
plugin.filePath.includes("equicordplugins/");
|
||||||
|
|
||||||
|
const shouldInclude =
|
||||||
|
(serviceKey === "equicord" && isEquicordPlugin) ||
|
||||||
|
(serviceKey === "vencord" && !isEquicordPlugin);
|
||||||
|
|
||||||
|
if (shouldInclude) {
|
||||||
|
for (const author of plugin.authors) {
|
||||||
|
if (author.id) {
|
||||||
|
contributors.add(author.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const badgeDetails =
|
||||||
|
serviceKey === "vencord"
|
||||||
|
? {
|
||||||
|
tooltip: "Vencord Contributor",
|
||||||
|
badge: "https://vencord.dev/assets/favicon.png",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
tooltip: "Equicord Contributor",
|
||||||
|
badge: "https://i.imgur.com/57ATLZu.png",
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const authorId of contributors) {
|
||||||
|
if (!data[authorId]) {
|
||||||
|
data[authorId] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasContributorBadge = data[authorId].some(
|
||||||
|
(badge) => badge.tooltip === badgeDetails.tooltip,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasContributorBadge) {
|
||||||
|
data[authorId].push(badgeDetails);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "nekocord": {
|
||||||
|
if (typeof service.url === "string") {
|
||||||
|
const res = await fetch(service.url, {
|
||||||
|
headers: {
|
||||||
|
"User-Agent": `BadgeAPI/1.0 ${gitUrl}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
data = (await res.json()) as NekocordData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "reviewdb": {
|
||||||
|
if (typeof service.url === "string") {
|
||||||
|
const res = await fetch(service.url, {
|
||||||
|
headers: {
|
||||||
|
"User-Agent": `BadgeAPI/1.0 ${gitUrl}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
data = (await res.json()) as ReviewDbData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "discord":
|
||||||
|
case "enmity":
|
||||||
|
return;
|
||||||
|
|
||||||
|
default:
|
||||||
|
echo.warn(`Unknown service type: ${serviceKey}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
const now = Date.now();
|
||||||
|
await Promise.all([
|
||||||
|
redis.set(cacheKey, JSON.stringify(data)),
|
||||||
|
redis.set(timestampKey, now.toString()),
|
||||||
|
redis.expire(cacheKey, redisTtl * 2),
|
||||||
|
redis.expire(timestampKey, redisTtl * 2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
echo.debug(`Updated cache for service: ${service.service}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
echo.warn({
|
||||||
|
message: `Failed to fetch data for service: ${service.service}`,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getServiceData(serviceKey: string): Promise<BadgeServiceData | null> {
|
||||||
|
const cacheKey = `${this.CACHE_PREFIX}${serviceKey}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cached = await redis.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return JSON.parse(cached) as BadgeServiceData;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
echo.warn({
|
||||||
|
message: `Failed to get cached data for service: ${serviceKey}`,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVencordEquicordData(
|
||||||
|
serviceKey: string,
|
||||||
|
): Promise<VencordEquicordData | null> {
|
||||||
|
const data = await this.getServiceData(serviceKey);
|
||||||
|
if (data && (serviceKey === "vencord" || serviceKey === "equicord")) {
|
||||||
|
return data as VencordEquicordData;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNekocordData(): Promise<NekocordData | null> {
|
||||||
|
const data = await this.getServiceData("nekocord");
|
||||||
|
if (data) {
|
||||||
|
return data as NekocordData;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getReviewDbData(): Promise<ReviewDbData | null> {
|
||||||
|
const data = await this.getServiceData("reviewdb");
|
||||||
|
if (data) {
|
||||||
|
return data as ReviewDbData;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async forceUpdateService(serviceName: string): Promise<void> {
|
||||||
|
const service = badgeServices.find(
|
||||||
|
(s: BadgeService) =>
|
||||||
|
s.service.toLowerCase() === serviceName.toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (service) {
|
||||||
|
await this.updateServiceData(service);
|
||||||
|
echo.info(`Force updated service: ${serviceName}`);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Service not found: ${serviceName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const badgeCacheManager = new BadgeCacheManager();
|
260
src/lib/badges.ts
Normal file
|
@ -0,0 +1,260 @@
|
||||||
|
import { echo } from "@atums/echo";
|
||||||
|
import { discordBadgeDetails, discordBadges } from "@config";
|
||||||
|
import { badgeServices, botToken, redisTtl } from "@config";
|
||||||
|
import { badgeCacheManager } from "@lib/badgeCache";
|
||||||
|
import { redis } from "bun";
|
||||||
|
|
||||||
|
function getRequestOrigin(request: Request): string {
|
||||||
|
const headers = request.headers;
|
||||||
|
const forwardedProto = headers.get("X-Forwarded-Proto") || "http";
|
||||||
|
const host = headers.get("Host") || new URL(request.url).host;
|
||||||
|
return `${forwardedProto}://${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBadges(
|
||||||
|
userId: string | undefined,
|
||||||
|
services: string[],
|
||||||
|
options?: FetchBadgesOptions,
|
||||||
|
request?: Request,
|
||||||
|
): Promise<BadgeResult> {
|
||||||
|
const { nocache = false, separated = false } = options ?? {};
|
||||||
|
const results: Record<string, Badge[]> = {};
|
||||||
|
|
||||||
|
if (!userId || !Array.isArray(services) || services.length === 0) {
|
||||||
|
return separated ? results : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const userCachePromises = services.map(async (service) => {
|
||||||
|
const serviceKey = service.toLowerCase();
|
||||||
|
const userCacheKey = `user_badges:${serviceKey}:${userId}`;
|
||||||
|
|
||||||
|
if (!nocache) {
|
||||||
|
try {
|
||||||
|
const cached = await redis.get(userCacheKey);
|
||||||
|
if (cached) {
|
||||||
|
const parsed: Badge[] = JSON.parse(cached);
|
||||||
|
results[serviceKey] = parsed;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const cacheHits = await Promise.all(userCachePromises);
|
||||||
|
|
||||||
|
const servicesToFetch = services.filter((_, index) => !cacheHits[index]);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
servicesToFetch.map(async (service) => {
|
||||||
|
const entry = badgeServices.find(
|
||||||
|
(s) => s.service.toLowerCase() === service.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
const serviceKey = service.toLowerCase();
|
||||||
|
const result: Badge[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (serviceKey) {
|
||||||
|
case "vencord":
|
||||||
|
case "equicord": {
|
||||||
|
const serviceData =
|
||||||
|
await badgeCacheManager.getVencordEquicordData(serviceKey);
|
||||||
|
if (!serviceData) {
|
||||||
|
echo.warn(`No cached data for service: ${serviceKey}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userBadges = serviceData[userId];
|
||||||
|
if (Array.isArray(userBadges)) {
|
||||||
|
for (const badgeItem of userBadges) {
|
||||||
|
result.push({
|
||||||
|
tooltip: badgeItem.tooltip,
|
||||||
|
badge: badgeItem.badge,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "nekocord": {
|
||||||
|
const serviceData = await badgeCacheManager.getNekocordData();
|
||||||
|
if (!serviceData) {
|
||||||
|
echo.warn(`No cached data for service: ${serviceKey}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userBadgeIds = serviceData.users?.[userId]?.badges;
|
||||||
|
if (Array.isArray(userBadgeIds)) {
|
||||||
|
for (const id of userBadgeIds) {
|
||||||
|
const badgeInfo = serviceData.badges?.[id];
|
||||||
|
if (badgeInfo) {
|
||||||
|
result.push({
|
||||||
|
tooltip: badgeInfo.name,
|
||||||
|
badge: badgeInfo.image,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "reviewdb": {
|
||||||
|
const serviceData = await badgeCacheManager.getReviewDbData();
|
||||||
|
if (!serviceData) {
|
||||||
|
echo.warn(`No cached data for service: ${serviceKey}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const badgeItem of serviceData) {
|
||||||
|
if (badgeItem.discordID === userId) {
|
||||||
|
result.push({
|
||||||
|
tooltip: badgeItem.name,
|
||||||
|
badge: badgeItem.icon,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "enmity": {
|
||||||
|
if (typeof entry.url !== "function") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlResult = entry.url(userId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof urlResult !== "object" ||
|
||||||
|
typeof urlResult.user !== "string" ||
|
||||||
|
typeof urlResult.badge !== "function"
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRes = await fetch(urlResult.user);
|
||||||
|
if (!userRes.ok) break;
|
||||||
|
|
||||||
|
const badgeIds = await userRes.json();
|
||||||
|
if (!Array.isArray(badgeIds)) break;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
badgeIds.map(async (id: string) => {
|
||||||
|
try {
|
||||||
|
const badgeRes = await fetch(urlResult.badge(id));
|
||||||
|
if (!badgeRes.ok) return;
|
||||||
|
|
||||||
|
const badge: EnmityBadgeItem = await badgeRes.json();
|
||||||
|
if (!badge?.name || !badge?.url?.dark) return;
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
tooltip: badge.name,
|
||||||
|
badge: badge.url.dark,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
echo.warn({
|
||||||
|
message: `Failed to fetch Enmity badge ${id}`,
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "discord": {
|
||||||
|
if (!botToken) {
|
||||||
|
echo.warn("Discord bot token not configured");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof entry.url !== "function") {
|
||||||
|
echo.warn("Discord service URL should be a function");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = entry.url(userId);
|
||||||
|
if (typeof url !== "string") {
|
||||||
|
echo.warn("Discord URL function should return a string");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bot ${botToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
echo.warn(
|
||||||
|
`Discord API request failed with status: ${res.status}`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: DiscordUserData = await res.json();
|
||||||
|
const origin = request ? getRequestOrigin(request) : "";
|
||||||
|
|
||||||
|
if (data.avatar?.startsWith("a_")) {
|
||||||
|
result.push({
|
||||||
|
tooltip: "Discord Nitro",
|
||||||
|
badge: `${origin}/public/badges/discord/NITRO.svg`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.flags === "number") {
|
||||||
|
for (const [flag, bitwise] of Object.entries(discordBadges)) {
|
||||||
|
if (data.flags & bitwise) {
|
||||||
|
const badge =
|
||||||
|
discordBadgeDetails[
|
||||||
|
flag as keyof typeof discordBadgeDetails
|
||||||
|
];
|
||||||
|
if (badge) {
|
||||||
|
result.push({
|
||||||
|
tooltip: badge.tooltip,
|
||||||
|
badge: `${origin}${badge.icon}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
echo.warn(`Unknown service: ${serviceKey}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
result.length > 0 ||
|
||||||
|
serviceKey === "discord" ||
|
||||||
|
serviceKey === "enmity"
|
||||||
|
) {
|
||||||
|
results[serviceKey] = result;
|
||||||
|
if (!nocache) {
|
||||||
|
const userCacheKey = `user_badges:${serviceKey}:${userId}`;
|
||||||
|
await redis.set(userCacheKey, JSON.stringify(result));
|
||||||
|
await redis.expire(userCacheKey, Math.min(redisTtl, 900));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
echo.warn({
|
||||||
|
message: `Failed to fetch badges for service ${serviceKey}`,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (separated) return results;
|
||||||
|
|
||||||
|
const combined: Badge[] = [];
|
||||||
|
for (const group of Object.values(results)) {
|
||||||
|
combined.push(...group);
|
||||||
|
}
|
||||||
|
return combined;
|
||||||
|
}
|
14
src/lib/char.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
function validateID(id: string | undefined): boolean {
|
||||||
|
if (!id) return false;
|
||||||
|
|
||||||
|
return /^\d{17,20}$/.test(id.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseServices(input: string): string[] {
|
||||||
|
return input
|
||||||
|
.split(/[\s,]+/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { validateID, parseServices };
|
|
@ -1,6 +1,6 @@
|
||||||
import { badgeServices } from "@config/environment";
|
import { badgeServices } from "@config";
|
||||||
import { fetchBadges } from "@helpers/badges";
|
import { fetchBadges } from "@lib/badges";
|
||||||
import { parseServices, validateID } from "@helpers/char";
|
import { parseServices, validateID } from "@lib/char";
|
||||||
|
|
||||||
function isValidServices(services: string[]): boolean {
|
function isValidServices(services: string[]): boolean {
|
||||||
if (!Array.isArray(services)) return false;
|
if (!Array.isArray(services)) return false;
|
||||||
|
@ -18,72 +18,73 @@ const routeDef: RouteDef = {
|
||||||
|
|
||||||
async function handler(request: ExtendedRequest): Promise<Response> {
|
async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
const { id: userId } = request.params;
|
const { id: userId } = request.params;
|
||||||
const { services, cache, seperated } = request.query;
|
const { services, cache = "true", seperated = "false" } = request.query;
|
||||||
|
|
||||||
let validServices: string[];
|
|
||||||
|
|
||||||
if (!validateID(userId)) {
|
if (!validateID(userId)) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
error: "Invalid Discord User ID",
|
error: "Invalid Discord User ID. Must be 17-20 digits.",
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
},
|
},
|
||||||
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let validServices: string[];
|
||||||
|
const availableServices = badgeServices.map((b) => b.service);
|
||||||
|
|
||||||
if (services) {
|
if (services) {
|
||||||
const parsed = parseServices(services);
|
const parsed = parseServices(services);
|
||||||
|
if (parsed.length === 0) {
|
||||||
if (parsed.length > 0) {
|
return Response.json(
|
||||||
if (!isValidServices(parsed)) {
|
{
|
||||||
return Response.json(
|
status: 400,
|
||||||
{
|
error: "No valid services provided",
|
||||||
status: 400,
|
availableServices,
|
||||||
error: "Invalid Services",
|
},
|
||||||
},
|
{ status: 400 },
|
||||||
{
|
);
|
||||||
status: 400,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
validServices = parsed;
|
|
||||||
} else {
|
|
||||||
validServices = badgeServices.map((b) => b.service);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isValidServices(parsed)) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
error: "Invalid service(s) provided",
|
||||||
|
availableServices,
|
||||||
|
provided: parsed,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
validServices = parsed;
|
||||||
} else {
|
} else {
|
||||||
validServices = badgeServices.map((b) => b.service);
|
validServices = availableServices;
|
||||||
}
|
}
|
||||||
|
|
||||||
const badges: BadgeResult = await fetchBadges(userId, validServices, {
|
const badges = await fetchBadges(
|
||||||
nocache: cache !== "true",
|
userId,
|
||||||
separated: seperated === "true",
|
validServices,
|
||||||
});
|
{
|
||||||
|
nocache: cache !== "true",
|
||||||
|
separated: seperated === "true",
|
||||||
|
},
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
|
||||||
if (badges instanceof Error) {
|
const isEmpty = Array.isArray(badges)
|
||||||
return Response.json(
|
? badges.length === 0
|
||||||
{
|
: Object.keys(badges).length === 0;
|
||||||
status: 500,
|
|
||||||
error: badges.message,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (badges.length === 0) {
|
if (isEmpty) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
status: 404,
|
status: 404,
|
||||||
error: "No Badges Found",
|
error: "No badges found for this user",
|
||||||
},
|
services: validServices,
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
},
|
},
|
||||||
|
{ status: 404 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -100,9 +101,6 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||||
"Access-Control-Allow-Headers": "Content-Type",
|
"Access-Control-Allow-Headers": "Content-Type",
|
||||||
"Access-Control-Max-Age": "86400",
|
|
||||||
"Access-Control-Allow-Credentials": "true",
|
|
||||||
"Access-Control-Expose-Headers": "Content-Type",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
89
src/routes/health.ts
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
import { redis } from "bun";
|
||||||
|
|
||||||
|
const routeDef: RouteDef = {
|
||||||
|
method: "GET",
|
||||||
|
accepts: "*/*",
|
||||||
|
returns: "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handler(): Promise<Response> {
|
||||||
|
const health: HealthResponse = {
|
||||||
|
status: "ok",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: process.uptime(),
|
||||||
|
services: {
|
||||||
|
redis: "unknown",
|
||||||
|
},
|
||||||
|
cache: {
|
||||||
|
lastFetched: {},
|
||||||
|
nextUpdate: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await redis.connect();
|
||||||
|
health.services.redis = "ok";
|
||||||
|
} catch {
|
||||||
|
health.services.redis = "error";
|
||||||
|
health.status = "degraded";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (health.services.redis === "ok") {
|
||||||
|
const services = ["vencord", "equicord", "nekocord", "reviewdb"];
|
||||||
|
const timestampPrefix = "badge_cache_timestamp:";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const timestamps = await Promise.all(
|
||||||
|
services.map(async (service) => {
|
||||||
|
const timestamp = await redis.get(`${timestampPrefix}${service}`);
|
||||||
|
return {
|
||||||
|
service,
|
||||||
|
timestamp: timestamp ? Number.parseInt(timestamp, 10) : null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastFetched: Record<string, CacheInfo> = {};
|
||||||
|
let oldestTimestamp: number | null = null;
|
||||||
|
|
||||||
|
for (const { service, timestamp } of timestamps) {
|
||||||
|
if (timestamp) {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
lastFetched[service] = {
|
||||||
|
timestamp: date.toISOString(),
|
||||||
|
age: `${Math.floor((Date.now() - timestamp) / 1000)}s ago`,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!oldestTimestamp || timestamp < oldestTimestamp) {
|
||||||
|
oldestTimestamp = timestamp;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lastFetched[service] = {
|
||||||
|
timestamp: null,
|
||||||
|
age: "never",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
health.cache.lastFetched = lastFetched;
|
||||||
|
|
||||||
|
if (oldestTimestamp) {
|
||||||
|
const nextUpdate = new Date(oldestTimestamp + 60 * 60 * 1000);
|
||||||
|
health.cache.nextUpdate = nextUpdate.toISOString();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
health.cache.lastFetched = { error: "Failed to fetch cache timestamps" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = health.status === "ok" ? 200 : 503;
|
||||||
|
|
||||||
|
return Response.json(health, {
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { handler, routeDef };
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { badgeServices, getServiceDescription, gitUrl } from "@config";
|
||||||
|
|
||||||
const routeDef: RouteDef = {
|
const routeDef: RouteDef = {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
accepts: "*/*",
|
accepts: "*/*",
|
||||||
|
@ -8,15 +10,56 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
const endPerf: number = Date.now();
|
const endPerf: number = Date.now();
|
||||||
const perf: number = endPerf - request.startPerf;
|
const perf: number = endPerf - request.startPerf;
|
||||||
|
|
||||||
const { query, params } = request;
|
const response = {
|
||||||
|
name: "Badge Aggregator API",
|
||||||
const response: Record<string, unknown> = {
|
description:
|
||||||
perf,
|
"A fast Discord badge aggregation API built with Bun and Redis caching",
|
||||||
query,
|
version: "1.0.0",
|
||||||
params,
|
author: "creations.works",
|
||||||
|
repository: gitUrl,
|
||||||
|
performance: {
|
||||||
|
responseTime: `${perf}ms`,
|
||||||
|
uptime: `${process.uptime()}s`,
|
||||||
|
},
|
||||||
|
routes: {
|
||||||
|
"GET /": "API information and available routes",
|
||||||
|
"GET /:userId": "Get badges for a Discord user",
|
||||||
|
"GET /health": "Health check endpoint",
|
||||||
|
},
|
||||||
|
endpoints: {
|
||||||
|
badges: {
|
||||||
|
path: "/:userId",
|
||||||
|
method: "GET",
|
||||||
|
description: "Fetch badges for a Discord user",
|
||||||
|
parameters: {
|
||||||
|
path: {
|
||||||
|
userId: "Discord User ID (17-20 digits)",
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
services: "Comma/space separated list of services (optional)",
|
||||||
|
cache: "Enable/disable caching (true/false, default: true)",
|
||||||
|
seperated:
|
||||||
|
"Return results grouped by service (true/false, default: false)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
example: "/:userId?services=discord,vencord&seperated=true&cache=true",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
supportedServices: badgeServices.map((service) => ({
|
||||||
|
name: service.service,
|
||||||
|
description: getServiceDescription(service.service),
|
||||||
|
})),
|
||||||
|
ratelimit: {
|
||||||
|
window: "60 seconds",
|
||||||
|
requests: 60,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return Response.json(response);
|
return Response.json(response, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "public, max-age=300",
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export { handler, routeDef };
|
export { handler, routeDef };
|
||||||
|
|
151
src/server.ts
|
@ -1,14 +1,14 @@
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { environment } from "@config/environment";
|
import { Echo, echo } from "@atums/echo";
|
||||||
import { logger } from "@helpers/logger";
|
import { environment } from "@config";
|
||||||
import {
|
import {
|
||||||
type BunFile,
|
type BunFile,
|
||||||
FileSystemRouter,
|
FileSystemRouter,
|
||||||
type MatchedRoute,
|
type MatchedRoute,
|
||||||
type Serve,
|
type Server,
|
||||||
} from "bun";
|
} from "bun";
|
||||||
|
|
||||||
import { webSocketHandler } from "@/websocket";
|
import { webSocketHandler } from "@websocket";
|
||||||
|
|
||||||
class ServerHandler {
|
class ServerHandler {
|
||||||
private router: FileSystemRouter;
|
private router: FileSystemRouter;
|
||||||
|
@ -19,14 +19,14 @@ class ServerHandler {
|
||||||
) {
|
) {
|
||||||
this.router = new FileSystemRouter({
|
this.router = new FileSystemRouter({
|
||||||
style: "nextjs",
|
style: "nextjs",
|
||||||
dir: "./src/routes",
|
dir: resolve("src", "routes"),
|
||||||
fileExtensions: [".ts"],
|
fileExtensions: [".ts"],
|
||||||
origin: `http://${this.host}:${this.port}`,
|
origin: `http://${this.host}:${this.port}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public initialize(): void {
|
public initialize(): void {
|
||||||
const server: Serve = Bun.serve({
|
const server: Server = Bun.serve({
|
||||||
port: this.port,
|
port: this.port,
|
||||||
hostname: this.host,
|
hostname: this.host,
|
||||||
fetch: this.handleRequest.bind(this),
|
fetch: this.handleRequest.bind(this),
|
||||||
|
@ -37,16 +37,16 @@ class ServerHandler {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(
|
const echoChild = new Echo({ disableFile: true });
|
||||||
`Server running at http://${server.hostname}:${server.port}`,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logRoutes();
|
echoChild.info(
|
||||||
|
`Server running at http://${server.hostname}:${server.port}`,
|
||||||
|
);
|
||||||
|
this.logRoutes(echoChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
private logRoutes(): void {
|
private logRoutes(echo: Echo): void {
|
||||||
logger.info("Available routes:");
|
echo.info("Available routes:");
|
||||||
|
|
||||||
const sortedRoutes: [string, string][] = Object.entries(
|
const sortedRoutes: [string, string][] = Object.entries(
|
||||||
this.router.routes,
|
this.router.routes,
|
||||||
|
@ -55,14 +55,19 @@ class ServerHandler {
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const [path, filePath] of sortedRoutes) {
|
for (const [path, filePath] of sortedRoutes) {
|
||||||
logger.info(`Route: ${path}, File: ${filePath}`);
|
echo.info(`Route: ${path}, File: ${filePath}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async serveStaticFile(pathname: string): Promise<Response> {
|
private async serveStaticFile(
|
||||||
try {
|
request: ExtendedRequest,
|
||||||
let filePath: string;
|
pathname: string,
|
||||||
|
ip: string,
|
||||||
|
): Promise<Response> {
|
||||||
|
let filePath: string;
|
||||||
|
let response: Response;
|
||||||
|
|
||||||
|
try {
|
||||||
if (pathname === "/favicon.ico") {
|
if (pathname === "/favicon.ico") {
|
||||||
filePath = resolve("public", "assets", "favicon.ico");
|
filePath = resolve("public", "assets", "favicon.ico");
|
||||||
} else {
|
} else {
|
||||||
|
@ -73,35 +78,98 @@ class ServerHandler {
|
||||||
|
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
const fileContent: ArrayBuffer = await file.arrayBuffer();
|
const fileContent: ArrayBuffer = await file.arrayBuffer();
|
||||||
const contentType: string = file.type || "application/octet-stream";
|
const contentType: string = file.type ?? "application/octet-stream";
|
||||||
|
|
||||||
return new Response(fileContent, {
|
response = new Response(fileContent, {
|
||||||
headers: { "Content-Type": contentType },
|
headers: { "Content-Type": contentType },
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
echo.warn(`File not found: ${filePath}`);
|
||||||
|
response = new Response("Not Found", { status: 404 });
|
||||||
}
|
}
|
||||||
logger.warn(`File not found: ${filePath}`);
|
|
||||||
return new Response("Not Found", { status: 404 });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error([`Error serving static file: ${pathname}`, error as Error]);
|
echo.error({
|
||||||
return new Response("Internal Server Error", { status: 500 });
|
message: `Error serving static file: ${pathname}`,
|
||||||
|
error: error as Error,
|
||||||
|
});
|
||||||
|
response = new Response("Internal Server Error", { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.logRequest(request, response, ip);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private logRequest(
|
||||||
|
request: ExtendedRequest,
|
||||||
|
response: Response,
|
||||||
|
ip: string | undefined,
|
||||||
|
): void {
|
||||||
|
const pathname = new URL(request.url).pathname;
|
||||||
|
|
||||||
|
const ignoredStartsWith: string[] = ["/public"];
|
||||||
|
const ignoredPaths: string[] = ["/favicon.ico"];
|
||||||
|
|
||||||
|
if (
|
||||||
|
ignoredStartsWith.some((prefix) => pathname.startsWith(prefix)) ||
|
||||||
|
ignoredPaths.includes(pathname)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo.custom(`${request.method}`, `${response.status}`, [
|
||||||
|
request.url,
|
||||||
|
`${(performance.now() - request.startPerf).toFixed(2)}ms`,
|
||||||
|
ip || "unknown",
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleRequest(
|
private async handleRequest(
|
||||||
request: Request,
|
request: Request,
|
||||||
server: BunServer,
|
server: Server,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const extendedRequest: ExtendedRequest = request as ExtendedRequest;
|
const extendedRequest: ExtendedRequest = request as ExtendedRequest;
|
||||||
extendedRequest.startPerf = performance.now();
|
extendedRequest.startPerf = performance.now();
|
||||||
|
|
||||||
|
const headers = request.headers;
|
||||||
|
let ip = server.requestIP(request)?.address;
|
||||||
|
let response: Response;
|
||||||
|
|
||||||
|
if (!ip || ip.startsWith("172.") || ip === "127.0.0.1") {
|
||||||
|
ip =
|
||||||
|
headers.get("CF-Connecting-IP")?.trim() ||
|
||||||
|
headers.get("X-Real-IP")?.trim() ||
|
||||||
|
headers.get("X-Forwarded-For")?.split(",")[0]?.trim() ||
|
||||||
|
"unknown";
|
||||||
|
}
|
||||||
|
|
||||||
const pathname: string = new URL(request.url).pathname;
|
const pathname: string = new URL(request.url).pathname;
|
||||||
|
|
||||||
|
const baseDir = resolve("public", "custom");
|
||||||
|
const customPath = resolve(baseDir, pathname.slice(1));
|
||||||
|
|
||||||
|
if (!customPath.startsWith(baseDir)) {
|
||||||
|
response = new Response("Forbidden", { status: 403 });
|
||||||
|
this.logRequest(extendedRequest, response, ip);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customFile = Bun.file(customPath);
|
||||||
|
if (await customFile.exists()) {
|
||||||
|
const content = await customFile.arrayBuffer();
|
||||||
|
const type: string = customFile.type ?? "application/octet-stream";
|
||||||
|
response = new Response(content, {
|
||||||
|
headers: { "Content-Type": type },
|
||||||
|
});
|
||||||
|
this.logRequest(extendedRequest, response, ip);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname.startsWith("/public") || pathname === "/favicon.ico") {
|
if (pathname.startsWith("/public") || pathname === "/favicon.ico") {
|
||||||
return await this.serveStaticFile(pathname);
|
return await this.serveStaticFile(extendedRequest, pathname, ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
const match: MatchedRoute | null = this.router.match(request);
|
const match: MatchedRoute | null = this.router.match(request);
|
||||||
let requestBody: unknown = {};
|
let requestBody: unknown = {};
|
||||||
let response: Response;
|
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
const { filePath, params, query } = match;
|
const { filePath, params, query } = match;
|
||||||
|
@ -110,7 +178,7 @@ class ServerHandler {
|
||||||
const routeModule: RouteModule = await import(filePath);
|
const routeModule: RouteModule = await import(filePath);
|
||||||
const contentType: string | null = request.headers.get("Content-Type");
|
const contentType: string | null = request.headers.get("Content-Type");
|
||||||
const actualContentType: string | null = contentType
|
const actualContentType: string | null = contentType
|
||||||
? contentType.split(";")[0].trim()
|
? (contentType.split(";")[0]?.trim() ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
@ -199,7 +267,10 @@ class ServerHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.error([`Error handling route ${request.url}:`, error as Error]);
|
echo.error({
|
||||||
|
message: `Error handling route ${request.url}`,
|
||||||
|
error: error,
|
||||||
|
});
|
||||||
|
|
||||||
response = Response.json(
|
response = Response.json(
|
||||||
{
|
{
|
||||||
|
@ -221,31 +292,11 @@ class ServerHandler {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = request.headers;
|
this.logRequest(extendedRequest, response, ip);
|
||||||
let ip = server.requestIP(request)?.address;
|
|
||||||
|
|
||||||
if (!ip || ip.startsWith("172.") || ip === "127.0.0.1") {
|
|
||||||
ip =
|
|
||||||
headers.get("CF-Connecting-IP")?.trim() ||
|
|
||||||
headers.get("X-Real-IP")?.trim() ||
|
|
||||||
headers.get("X-Forwarded-For")?.split(",")[0].trim() ||
|
|
||||||
"unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.custom(
|
|
||||||
`[${request.method}]`,
|
|
||||||
`(${response.status})`,
|
|
||||||
[
|
|
||||||
request.url,
|
|
||||||
`${(performance.now() - extendedRequest.startPerf).toFixed(2)}ms`,
|
|
||||||
ip || "unknown",
|
|
||||||
],
|
|
||||||
"90",
|
|
||||||
);
|
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverHandler: ServerHandler = new ServerHandler(
|
const serverHandler: ServerHandler = new ServerHandler(
|
||||||
environment.port,
|
environment.port,
|
||||||
environment.host,
|
environment.host,
|
||||||
|
|
|
@ -1,27 +1,33 @@
|
||||||
import { logger } from "@helpers/logger";
|
import { echo } from "@atums/echo";
|
||||||
import type { ServerWebSocket } from "bun";
|
import type { ServerWebSocket } from "bun";
|
||||||
|
|
||||||
class WebSocketHandler {
|
class WebSocketHandler {
|
||||||
public handleMessage(ws: ServerWebSocket, message: string): void {
|
public handleMessage(ws: ServerWebSocket, message: string): void {
|
||||||
logger.info(`WebSocket received: ${message}`);
|
echo.info(`WebSocket received: ${message}`);
|
||||||
try {
|
try {
|
||||||
ws.send(`You said: ${message}`);
|
ws.send(`You said: ${message}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(["WebSocket send error", error as Error]);
|
echo.error({
|
||||||
|
message: "WebSocket send error",
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public handleOpen(ws: ServerWebSocket): void {
|
public handleOpen(ws: ServerWebSocket): void {
|
||||||
logger.info("WebSocket connection opened.");
|
echo.info("WebSocket connection opened.");
|
||||||
try {
|
try {
|
||||||
ws.send("Welcome to the WebSocket server!");
|
ws.send("Welcome to the WebSocket server!");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(["WebSocket send error", error as Error]);
|
echo.error({
|
||||||
|
message: "WebSocket send error",
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public handleClose(ws: ServerWebSocket, code: number, reason: string): void {
|
public handleClose(_ws: ServerWebSocket, code: number, reason: string): void {
|
||||||
logger.warn(`WebSocket closed with code ${code}, reason: ${reason}`);
|
echo.info(`WebSocket closed with code ${code}, reason: ${reason}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,32 +2,30 @@
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"],
|
"@*": ["src/*"],
|
||||||
|
"@config": ["config/index.ts"],
|
||||||
"@config/*": ["config/*"],
|
"@config/*": ["config/*"],
|
||||||
"@types/*": ["types/*"],
|
"@types/*": ["types/*"],
|
||||||
"@helpers/*": ["src/helpers/*"]
|
"@lib/*": ["src/lib/*"]
|
||||||
},
|
},
|
||||||
"typeRoots": ["./src/types", "./node_modules/@types"],
|
"typeRoots": ["./types", "./node_modules/@types"],
|
||||||
// Enable latest features
|
|
||||||
"lib": ["ESNext", "DOM"],
|
"lib": ["ESNext", "DOM"],
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"jsx": "react-jsx",
|
"allowJs": false,
|
||||||
"allowJs": true,
|
|
||||||
// Bundler mode
|
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": false,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
// Best practices
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
// Some stricter flags (disabled by default)
|
"noUnusedLocals": true,
|
||||||
"noUnusedLocals": false,
|
"noUnusedParameters": true,
|
||||||
"noUnusedParameters": false,
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
"noPropertyAccessFromIndexSignature": false
|
"noPropertyAccessFromIndexSignature": false
|
||||||
},
|
},
|
||||||
"include": ["src", "types", "config"]
|
"include": ["src", "types"]
|
||||||
}
|
}
|
||||||
|
|
90
types/badge.d.ts
vendored
|
@ -9,3 +9,93 @@ interface FetchBadgesOptions {
|
||||||
nocache?: boolean;
|
nocache?: boolean;
|
||||||
separated?: boolean;
|
separated?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BadgeService = {
|
||||||
|
service: string;
|
||||||
|
url:
|
||||||
|
| string
|
||||||
|
| ((userId: string) => string)
|
||||||
|
| ((userId: string) => {
|
||||||
|
user: string;
|
||||||
|
badge: (id: string) => string;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
interface VencordEquicordData {
|
||||||
|
[userId: string]: Array<{
|
||||||
|
tooltip: string;
|
||||||
|
badge: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NekocordData {
|
||||||
|
users: {
|
||||||
|
[userId: string]: {
|
||||||
|
badges: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
badges: {
|
||||||
|
[badgeId: string]: {
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewDbData
|
||||||
|
extends Array<{
|
||||||
|
discordID: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
}> {}
|
||||||
|
|
||||||
|
type BadgeServiceData = VencordEquicordData | NekocordData | ReviewDbData;
|
||||||
|
|
||||||
|
interface VencordBadgeItem {
|
||||||
|
tooltip: string;
|
||||||
|
badge: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NekocordBadgeInfo {
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewDbBadgeItem {
|
||||||
|
discordID: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnmityBadgeItem {
|
||||||
|
name: string;
|
||||||
|
url: {
|
||||||
|
dark: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscordUserData {
|
||||||
|
avatar: string;
|
||||||
|
flags: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginData {
|
||||||
|
hasPatches: boolean;
|
||||||
|
hasCommands: boolean;
|
||||||
|
enabledByDefault: boolean;
|
||||||
|
required: boolean;
|
||||||
|
tags: string[];
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
authors: Array<{
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
filePath: string;
|
||||||
|
commands?: Array<{
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
|
dependencies?: string[];
|
||||||
|
target?: string;
|
||||||
|
}
|
||||||
|
|
14
types/bun.d.ts
vendored
|
@ -1,14 +1,8 @@
|
||||||
import type { Server } from "bun";
|
|
||||||
|
|
||||||
type Query = Record<string, string>;
|
type Query = Record<string, string>;
|
||||||
type Params = Record<string, string>;
|
type Params = Record<string, string>;
|
||||||
|
|
||||||
declare global {
|
interface ExtendedRequest extends Request {
|
||||||
type BunServer = Server;
|
startPerf: number;
|
||||||
|
query: Query;
|
||||||
interface ExtendedRequest extends Request {
|
params: Params;
|
||||||
startPerf: number;
|
|
||||||
query: Query;
|
|
||||||
params: Params;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
5
types/config.d.ts
vendored
|
@ -3,8 +3,3 @@ type Environment = {
|
||||||
host: string;
|
host: string;
|
||||||
development: boolean;
|
development: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type badgeURLMap = {
|
|
||||||
service: string;
|
|
||||||
url: string | ((userId: string) => string);
|
|
||||||
};
|
|
||||||
|
|
17
types/health.d.ts
vendored
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
interface CacheInfo {
|
||||||
|
timestamp: string | null;
|
||||||
|
age: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HealthResponse {
|
||||||
|
status: "ok" | "degraded";
|
||||||
|
timestamp: string;
|
||||||
|
uptime: number;
|
||||||
|
services: {
|
||||||
|
redis: "ok" | "error" | "unknown";
|
||||||
|
};
|
||||||
|
cache: {
|
||||||
|
lastFetched: Record<string, CacheInfo> | { error: string };
|
||||||
|
nextUpdate: string | null;
|
||||||
|
};
|
||||||
|
}
|
9
types/logger.d.ts
vendored
|
@ -1,9 +0,0 @@
|
||||||
type ILogMessagePart = { value: string; color: string };
|
|
||||||
|
|
||||||
type ILogMessageParts = {
|
|
||||||
level: ILogMessagePart;
|
|
||||||
filename: ILogMessagePart;
|
|
||||||
readableTimestamp: ILogMessagePart;
|
|
||||||
message: ILogMessagePart;
|
|
||||||
[key: string]: ILogMessagePart;
|
|
||||||
};
|
|