Compare commits

...

58 Commits

Author SHA1 Message Date
Bas950
0785b49ced chore: release v1.2.2 2025-10-13 23:41:23 +02:00
Bas950
1e1e4f88b4 chore: remove cache headers 2025-10-13 23:41:03 +02:00
Bas950
9a5dbd3428 chore: release v1.2.1 2025-09-16 11:26:05 +02:00
Bas950
596b7fc759 fix: return image directly 2025-09-16 11:25:52 +02:00
Bas950
de305d76e6 chore: release v1.2.0 2025-09-15 11:44:10 +02:00
Bas950
7f520d775d feat: add file extension 2025-09-15 11:43:24 +02:00
Bas950
3df13b6147 chore: release v1.0.10 2025-07-15 17:19:56 +02:00
Bas950
1106c77649 feat: schema 1.15 2025-07-15 17:19:30 +02:00
Bas950
f121006db0 chore: release v1.0.9 2025-06-04 10:52:21 +02:00
Bas950
459d567a4b feat: v1.14 of schema 2025-06-04 10:51:57 +02:00
veryCrunchy
10278567fd chore: add Social Media Manager role and color to constants (#1084)
* chore: add Social Media Manager role and color to constants

* chore: bump version
2025-04-23 14:52:27 +02:00
Bas950
038b71326c chore: release v1.0.8 2025-01-27 10:03:59 +01:00
Bas950
4ecd1b6c0b chore: lint 2025-01-27 10:03:28 +01:00
Bas950
d37ca76318 chore: release v1.0.7 2025-01-27 10:02:50 +01:00
Bas950
4367f3f679 chore: add mobile and update descriptions 2025-01-27 10:02:42 +01:00
Bas950
9920bdf83e chore: release v1.0.6 2025-01-26 18:32:20 +01:00
Bas950
e4c9719b44 feat(schema-server): add v1.13 2025-01-26 18:32:01 +01:00
Florian Metz
19e249ce9a chore: release v1.0.7 2025-01-10 00:33:53 +01:00
veryCrunchy
3338915f4e chore(bot): rename presence to activity (#1077) 2025-01-10 00:30:13 +01:00
Bas950
3ec9eba478 chore: release v0.0.19 2024-12-21 12:20:45 +01:00
Bas950
d862584a30 chore: add environment variable to disable 2024-12-21 12:19:41 +01:00
Bas950
255f840275 chore: release v0.0.39 2024-12-19 20:33:57 +01:00
Bas950
12089fe773 chore: lint 2024-12-19 20:33:50 +01:00
Bas950
4b43e03ff0 chore: remove ip lookup 2024-12-19 20:33:08 +01:00
Bas950
d16ec114b0 chore: release v0.0.38 2024-12-19 20:27:29 +01:00
Bas950
8ea8904752 chore: release v1.0.5 2024-12-19 20:26:07 +01:00
Bas950
7fabdb8fe4 feat: new schema version 2024-12-19 20:25:17 +01:00
Bas van Zanten
2554b20b34 feat: extension version gauge (#1074)
* feat: extension version gauge

* chore: lint
2024-12-19 20:22:44 +01:00
Florian Metz
9e72d2cc7f chore: release v1.0.6 2024-12-19 10:47:55 +01:00
Florian Metz
4af1ff22f1 fix: show displayName 2024-12-19 10:47:15 +01:00
Bas950
f339035463 chore: release v0.0.18 2024-11-17 21:02:17 +01:00
Bas950
b67226dcdd feat: fix presence endpoints 2024-11-17 21:02:07 +01:00
Bas950
154acbc802 chore: release v0.0.17 2024-11-17 18:42:52 +01:00
Bas950
a1ed87eccf fix: add dbName 2024-11-17 18:42:43 +01:00
Florian Metz
cb4ecf7c95 chore: release v0.0.16 2024-11-17 02:12:40 +01:00
Florian Metz
26a0fee323 chore: fix type 2024-11-17 02:12:10 +01:00
Bas950
b48dc12c8f chore: release v0.0.15 2024-11-17 01:09:33 +01:00
Bas950
dcf3218d0b fix(api): metadata types 2024-11-17 01:09:09 +01:00
Bas950
bdcc05a300 chore: release v1.0.5 2024-11-17 01:02:43 +01:00
Bas950
51b4508a1c chore: change info message 2024-11-17 01:02:39 +01:00
Bas950
378671b267 chore: release v1.0.4 2024-11-17 00:58:11 +01:00
Bas950
53a02c98e0 fix: get presence list correctly 2024-11-17 00:57:26 +01:00
Bas van Zanten
b60c991fea Update presence.ts (#1072) 2024-11-10 16:56:18 +01:00
Florian Metz
3b178d70b9 chore: release v1.0.3 2024-10-08 09:56:28 +02:00
veryCrunchy
ef377d3f68 fix(discord-bot): update developer roles constant (#1070)
* chore: disable commitlint

* fix(discord-bot): update developer roles constant

* Update commit-msg
2024-10-08 09:27:10 +02:00
Bas950
d5acdefc45 chore: lint 2024-10-08 09:21:10 +02:00
Bas950
39ffee4126 chore: un-ingore config files 2024-10-08 09:16:22 +02:00
Bas950
ed30c593ad chore: release v1.0.2 2024-09-29 13:22:29 +02:00
Bas950
9ffa6addb3 feat(discord-bot): add beta command 2024-09-29 13:22:08 +02:00
Bas950
95fd02b513 chore: release v1.0.1 2024-09-26 12:27:56 +02:00
Bas950
3198ac3704 fix(discord-bot): fixes credits 2024-09-26 12:27:11 +02:00
Florian Metz
0d4b53f382 chore: release v1.0.0 2024-09-26 10:56:07 +02:00
Florian Metz
3600ef87a5 chore: update version 2024-09-26 10:55:59 +02:00
Bas van Zanten
55ffabb352 feat: discord-bot (#1069)
* feat: discord-bot

* feat: final things

* chore: add to matrix

* feat: add sentry

* chore: move some things
2024-09-26 10:11:07 +02:00
Bas950
5a90f95e58 chore: release v0.0.37 2024-09-24 17:07:06 +02:00
Bas950
64547dc0ef feat: ip data 2024-09-24 17:06:48 +02:00
Bas950
9cf3f93889 chore: release v0.0.36 2024-09-24 13:47:54 +02:00
Bas950
0e30a0d250 chore: scan keys instead 2024-09-24 13:47:39 +02:00
62 changed files with 3380 additions and 624 deletions

View File

@@ -20,6 +20,7 @@ jobs:
- website
- api-worker
- api-master
- discord-bot
steps:
- name: Checkout Repository
uses: actions/checkout@v4

View File

@@ -48,6 +48,7 @@ jobs:
- api-worker
- api-master
- website
- discord-bot
steps:
- name: Checkout Repository
uses: actions/checkout@v4

5
.gitignore vendored
View File

@@ -20,8 +20,9 @@ src/update.ini
*.app
*.xml.backup
*.js
!eslint.config.js
!*.config.js
coverage
*.tsbuildinfo
.DS_Store
.DS_Store
*.log

View File

@@ -1,14 +1,3 @@
declare module "ip-location-api" {
export function lookup(ip: string): Promise<{
latitude: number;
longitude: number;
country: string;
} | null>;
export function updateDb(options: { fields?: string[]; dataDir?: string; tmpDataDir?: string; smallMemory?: boolean }): Promise<void>;
export function reload(options: { fields?: string[]; dataDir?: string; tmpDataDir?: string; smallMemory?: boolean }): Promise<void>;
}
declare namespace NodeJS {
export interface ProcessEnv {
METRICS_DATABASE_URL?: string;

View File

@@ -1,7 +1,7 @@
{
"name": "@premid/api-master",
"type": "module",
"version": "0.0.35",
"version": "0.0.39",
"private": true,
"description": "PreMiD's api master",
"license": "MPL-2.0",
@@ -24,7 +24,6 @@
"debug": "^4.3.6",
"drizzle-orm": "^0.33.0",
"ioredis": "^5.3.2",
"ip-location-api": "^1.0.0",
"ky": "^1.7.2",
"p-limit": "^6.1.0",
"postgres": "^3.4.4",

View File

@@ -1,47 +1,3 @@
import { join } from "node:path";
import process from "node:process";
import { lookup, reload, updateDb } from "ip-location-api";
import { mainLog } from "../index.js";
const fields = ["latitude", "longitude", "country"];
const dataDir = join(process.cwd(), "data");
const tmpDataDir = join(process.cwd(), "tmp");
const smallMemory = true;
let initialized = false;
export async function lookupIp(ip: string): Promise<{ latitude: number; longitude: number; country: string } | undefined> {
if (!initialized) {
reloadIpLocationApi();
return undefined;
}
try {
return await lookup(ip) ?? undefined;
}
catch {
return undefined;
}
}
let reloading: Promise<void> | undefined = Promise.resolve();
let log: debug.Debugger | undefined;
export async function reloadIpLocationApi() {
log ??= mainLog.extend("IP-Location-API");
if (reloading)
return reloading;
reloading = new Promise((resolve, reject) => {
log?.("Reloading IP location API");
updateDb({ fields, dataDir, tmpDataDir, smallMemory }).then(async () => {
await reload({ fields, dataDir, tmpDataDir, smallMemory });
log?.("IP location API reloaded");
initialized = true;
reloading = undefined;
resolve();
}).catch(reject);
});
return reloading;
export async function lookupIp(_ip: string): Promise<{ latitude: number; longitude: number; country: string } | undefined> {
return undefined;
}

View File

@@ -4,6 +4,14 @@ import { register } from "../tracing.js";
export function setupServer() {
const server = http.createServer(async (req, res) => {
//* If it's a head request, just return 200
if (req.method === "HEAD")
return res.writeHead(200).end();
//* If it's a favicon request, just return 404
if (req.url === "/favicon.ico")
return res.writeHead(404).end();
//* Basic routing logic
res.writeHead(200, { "Content-Type": "text/plain" });
res.end(await register.metrics());

View File

@@ -0,0 +1,54 @@
import process from "node:process";
import pLimit from "p-limit";
import type { Gauge } from "prom-client";
import { mainLog, redis } from "../index.js";
export const updateExtensionVersionGaugeLimit = pLimit(1);
let log: debug.Debugger | undefined;
const scanCount = Number.parseInt(process.env.SCAN_COUNT || "1000", 10);
export async function updateExtensionVersionGauge(gauge: Gauge) {
await updateExtensionVersionGaugeLimit(async () => {
log ??= mainLog.extend("Extension-Version-Updates");
log?.("Starting extension version gauge update");
const pattern = "pmd-api.heartbeatUpdates.*";
let cursor: string = "0";
const versionCounts = new Map<string, number>();
do {
const [newCursor, keys] = await redis.scan(cursor, "MATCH", pattern, "COUNT", scanCount);
cursor = newCursor;
//* Use pipelining for batch Redis operations
const pipeline = redis.pipeline();
keys.forEach(key => pipeline.hmget(key, "extension_version"));
const hashes = await pipeline.exec();
if (!hashes) {
log?.("No hashes found");
return;
}
hashes.forEach(([err, hash]) => {
if (err || !Array.isArray(hash))
return;
const [version] = hash;
if (version && typeof version === "string")
versionCounts.set(version, (versionCounts.get(version) || 0) + 1);
});
} while (cursor !== "0");
log?.("Updating extension version gauge");
//* Batch update the gauge
gauge.reset();
for (const [version, count] of versionCounts) {
gauge.set({ version }, count);
}
log?.("Extension version gauge update completed");
});
}

View File

@@ -4,7 +4,6 @@ import debug from "debug";
import { clearOldSessions } from "./functions/clearOldSessions.js";
import createRedis from "./functions/createRedis.js";
import "./tracing.js";
// import { reloadIpLocationApi } from "./functions/lookupIp.js";
import { cleanupOldUserData } from "./functions/cleanupOldUserData.js";
import { setupServer } from "./functions/setupServer.js";
@@ -28,19 +27,6 @@ void new CronJob(
true,
);
// void new CronJob(
// // Every day at 9am
// "0 9 * * *",
// () => {
// reloadIpLocationApi();
// },
// undefined,
// true,
// undefined,
// undefined,
// true,
// );
void new CronJob(
// Every day at 1am
"0 1 * * *",

View File

@@ -1,8 +1,11 @@
import process from "node:process";
import { Counter, Gauge, Registry, collectDefaultMetrics } from "prom-client";
import { updateActivePresenceGauge, updateActivePresenceGaugeLimit } from "./functions/updateActivePresenceGauge.js";
import { updateExtensionVersionGauge, updateExtensionVersionGaugeLimit } from "./functions/updateVersionGauge.js";
import { redis } from "./index.js";
const scanCount = Number.parseInt(process.env.SCAN_COUNT || "1000", 10);
export const register = new Registry();
collectDefaultMetrics({ register });
@@ -11,7 +14,13 @@ export const activeSessionsCounter = new Counter({
help: "Number of active sessions",
async collect() {
this.reset();
const length = await redis.hlen("pmd-api.sessions");
let length = 0;
let cursor = "0";
do {
const reply = await redis.scan(cursor, "MATCH", "pmd-api.sessions.*", "COUNT", scanCount);
cursor = reply[0];
length += reply[1].length;
} while (cursor !== "0");
this.inc(length);
},
});
@@ -29,5 +38,17 @@ export const activePresencesCounter = new Gauge({
},
});
const versionCounter = new Gauge({
name: "extension_version",
help: "The version of the extension with the amount of users using it",
labelNames: ["version"],
async collect() {
this.reset();
updateExtensionVersionGaugeLimit.clearQueue();
await updateExtensionVersionGauge(this);
},
});
register.registerMetric(activeSessionsCounter);
register.registerMetric(activePresencesCounter);
register.registerMetric(versionCounter);

View File

@@ -3,5 +3,6 @@ declare namespace NodeJS {
NODE_ENV?: "development" | "production" | "test";
DATABASE_URL?: string;
SESSION_KEEP_ALIVE_INTERVAL?: string;
HEARTBEATS?: "true" | "false";
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "@premid/api-worker",
"type": "module",
"version": "0.0.14",
"version": "0.0.19",
"private": true,
"description": "PreMiD's api",
"license": "MPL-2.0",
@@ -36,7 +36,7 @@
"graphql-parse-resolve-info": "^4.13.0",
"graphql-yoga": "^5.6.0",
"ioredis": "^5.3.2",
"mongoose": "^8.5.1"
"mongoose": "^8.6.3"
},
"devDependencies": {
"@graphql-codegen/cli": "5.0.2",

View File

@@ -13,6 +13,7 @@ import { Socket } from "../classes/Socket.js";
import { resolvers } from "../graphql/resolvers/v5/index.js";
import { sessionKeepAlive } from "../routes/sessionKeepAlive.js";
import { featureFlags } from "../constants.js";
import { presences } from "../routes/presences.js";
import createRedis from "./createRedis.js";
export interface FastifyContext {
@@ -89,6 +90,7 @@ export default async function createServer() {
});
app.post("/v5/session-keep-alive", sessionKeepAlive);
app.get("/v5/presence/:service/:file", presences);
return app;
}

View File

@@ -1,3 +1,4 @@
import process from "node:process";
import { type } from "arktype";
import { GraphQLError } from "graphql";
import type { MutationResolvers } from "../../../../generated/graphql-v5.js";
@@ -32,18 +33,20 @@ const mutation: MutationResolvers["heartbeat"] = async (_parent, input, context)
// * Use Redis Hash with 'service' in the key to store heartbeat data
const redisKey = `pmd-api.heartbeatUpdates.${out.identifier}`;
await redis.hset(redisKey, {
service: out.presence?.service,
version: out.presence?.version,
language: out.presence?.language,
since: out.presence?.since.toString(),
extension_version: out.extension.version,
extension_language: out.extension.language,
extension_connected_app: out.extension.connected?.app?.toString(),
extension_connected_discord: out.extension.connected?.discord?.toString(),
ip_address: userIp,
});
await redis.expire(redisKey, 300);
if (process.env.HEARTBEATS !== "false") {
await redis.hset(redisKey, {
service: out.presence?.service,
version: out.presence?.version,
language: out.presence?.language,
since: out.presence?.since.toString(),
extension_version: out.extension.version,
extension_language: out.extension.language,
extension_connected_app: out.extension.connected?.app?.toString(),
extension_connected_discord: out.extension.connected?.discord?.toString(),
ip_address: userIp,
});
await redis.expire(redisKey, 300);
}
return {
__typename: "HeartbeatResult",

View File

@@ -19,6 +19,7 @@ type Presence {
}
type PresenceMetadata {
apiVersion: Int!
author: PresenceMetadataUser!
contributors: [PresenceMetadataUser!]
altnames: [String!]
@@ -49,14 +50,9 @@ type PresenceMetadataSettings {
id: String!
title: String
icon: String
if: PresenceMetadataSettingsIf # serialize
if: Scalar # serialize
placeholder: String
value: Scalar # serialize
values: Scalar # serialize
multiLanguage: Scalar # serialize
}
type PresenceMetadataSettingsIf {
propertyNames: String
patternProperties: Scalar
}

View File

@@ -16,7 +16,7 @@ Sentry.init({
if (!process.env.DATABASE_URL)
throw new Error("DATABASE_URL is not set");
await connect(process.env.DATABASE_URL, { appName: "PreMiD API" });
await connect(process.env.DATABASE_URL, { appName: "PreMiD API", dbName: "PreMiD" });
const server = await createServer();
const url = await server.listen({

View File

@@ -0,0 +1,34 @@
import { Presence } from "@premid/db";
import { type } from "arktype";
import type { FastifyReply, FastifyRequest } from "fastify";
const schema = type({
service: "string.trim",
file: "'metadata.json'|'presence.js'|'iframe.js'",
});
export async function presences(request: FastifyRequest, reply: FastifyReply) {
const out = schema(request.params);
if (out instanceof type.errors)
return reply.status(400).send({ code: "INVALID_PARAMS", message: out.message });
const service = decodeURIComponent(out.service);
const { file } = out;
const presence = await Presence.findOne({ "metadata.service": service });
if (!presence)
return reply.status(404).send({ code: "PRESENCE_NOT_FOUND", message: "The presence was not found" });
switch (file) {
case "metadata.json":
return reply.status(200).type("application/json").send(presence.metadata);
case "presence.js":
return reply.status(200).type("application/javascript").send(presence.presenceJs);
case "iframe.js":
if (!presence.iframeJs)
return reply.status(404).send({ code: "IFRAME_NOT_FOUND", message: "The presence does not have an iframe" });
return reply.status(200).type("application/javascript").send(presence.iframeJs);
}
}

View File

@@ -0,0 +1,25 @@
{
"name": "@premid/discord-bot",
"type": "module",
"version": "1.0.8",
"private": true,
"description": "PreMiD's discord bot",
"license": "MPL-2.0",
"main": "dist/index.js",
"files": [
"dist"
],
"scripts": {
"start": "node --enable-source-maps .",
"dev": "node --watch --env-file .env --enable-source-maps ."
},
"dependencies": {
"@premid/db": "workspace:*",
"@sentry/node": "^8.17.0",
"defu": "^6.1.4",
"discord.js": "^14.16.2",
"glob": "^11.0.0",
"mongoose": "^8.2.0",
"winston": "^3.14.2"
}
}

View File

@@ -0,0 +1,98 @@
import type { AutocompleteInteraction, ChatInputCommandInteraction } from "discord.js";
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, SlashCommandBuilder } from "discord.js";
import { Presence as Activity } from "@premid/db";
import { createStandardEmbed } from "../util/createStandardEmbed.js";
import type { Command } from "../util/loadCommands.js";
import { getActivityList } from "../util/activityList.js";
import { client } from "../constants.js";
export default {
data: new SlashCommandBuilder()
.setName("activity")
.setDescription("Search for an activity")
.addStringOption(option =>
option
.setName("query")
.setDescription("The activity to search for")
.setAutocomplete(true)
.setRequired(true),
),
autocomplete: async (interaction: AutocompleteInteraction) => {
const focusedValue = interaction.options.getFocused();
const activityList = getActivityList();
const filtered = activityList.filter(({ service }) => service.toLowerCase().includes(focusedValue.toLowerCase()));
return interaction.respond(filtered.slice(0, 25).map(({ service }) => ({ name: service, value: service })));
},
execute: async (interaction: ChatInputCommandInteraction) => {
const query = interaction.options.getString("query");
if (!query)
return interaction.reply({ content: "Please provide a query to search for", ephemeral: true });
const activity = await Activity.findOne({ name: query }, {
_id: false,
metadata: {
service: true,
author: { id: true },
contributors: { id: true },
url: true,
description: {
en: true,
},
logo: true,
color: true,
},
});
if (!activity)
return interaction.reply({ content: "Activity not found", ephemeral: true });
const embed = createStandardEmbed({
title: activity.metadata.service,
description: activity.metadata.description.en,
color: activity.metadata.color,
fields: activity.metadata.contributors?.length
? [{
name: "Contributors",
value: activity.metadata.contributors.map(contributor => `<@${contributor.id}>`).join(", "),
}]
: undefined,
});
embed.setURL(`https://${Array.isArray(activity.metadata.url) ? activity.metadata.url[0] : activity.metadata.url}`);
embed.setThumbnail(activity.metadata.logo);
const author = await client.users.fetch(activity.metadata.author.id).catch(() => null);
if (author) {
embed.setAuthor({
name: author.username,
iconURL: author.displayAvatarURL(),
});
}
return interaction.reply({ embeds: [embed], components: [
new ActionRowBuilder<ButtonBuilder>()
.addComponents(
new ButtonBuilder()
.setLabel("Open in Store")
.setURL(`https://premid.app/store/presences/${encodeURI(activity.metadata.service)}`)
.setStyle(ButtonStyle.Link),
),
] });
},
help: {
name: "activity",
value: "activity",
command: "/activity <query>",
commandDescription: "Search for an activity",
embed: createStandardEmbed({
title: "Command: /activity",
description: "Search for an activity",
fields: [
{ name: "Usage", value: "`/activity <query>`", inline: true },
{ name: "Example", value: "`/activity YouTube`", inline: true },
],
}),
},
} satisfies Command;

View File

@@ -0,0 +1,61 @@
import type { ChatInputCommandInteraction } from "discord.js";
import { SlashCommandBuilder } from "discord.js";
import { BetaUsers } from "@premid/db";
import { createStandardEmbed } from "../util/createStandardEmbed.js";
import type { Command } from "../util/loadCommands.js";
import { client, processEnv } from "../constants.js";
export default {
data: new SlashCommandBuilder()
.setName("beta")
.setDescription("Join or leave the beta program")
.addSubcommand(subcommand =>
subcommand
.setName("leave")
.setDescription("Leave the beta program"),
).addSubcommand(subcommand =>
subcommand
.setName("join")
.setDescription("Join the beta program"),
),
execute: async (interaction: ChatInputCommandInteraction) => {
if (!interaction.inGuild())
return;
if (interaction.options.getSubcommand() === "leave") {
const user = await BetaUsers.findOne({ userId: interaction.user.id });
if (!user) {
return interaction.reply({ content: "You are not a beta tester", ephemeral: true });
}
await BetaUsers.deleteOne({ userId: interaction.user.id });
const member = client.guilds.cache.get(interaction.guildId)?.members.cache.get(interaction.user.id);
await member?.roles.remove(processEnv.BETA_ROLE, "Left the beta program");
return interaction.reply({ content: "You are no longer a beta tester", ephemeral: true });
}
else if (interaction.options.getSubcommand() === "join") {
await BetaUsers.updateOne({ userId: interaction.user.id }, { $set: { userId: interaction.user.id } }, { upsert: true });
const member = client.guilds.cache.get(interaction.guildId)?.members.cache.get(interaction.user.id);
await member?.roles.add(processEnv.BETA_ROLE, "Joined the beta program");
return interaction.reply({ content: "You are now a beta tester", ephemeral: true });
}
else {
return interaction.reply({ content: "Invalid subcommand", ephemeral: true });
}
},
help: {
name: "beta",
value: "beta",
command: "/beta <join/leave>",
commandDescription: "Join or leave the beta program",
embed: createStandardEmbed({
title: "Command: /beta <join/leave>",
description: "Join or leave the beta program",
fields: [
{ name: "Usage", value: "`/beta <join/leave>`", inline: true },
{ name: "Example", value: "`/beta join`", inline: true },
],
}),
},
} satisfies Command;

View File

@@ -0,0 +1,83 @@
import {
ActionRowBuilder,
type AutocompleteInteraction,
ButtonBuilder,
ButtonStyle,
type ChatInputCommandInteraction,
SlashCommandBuilder,
} from "discord.js";
import { createStandardEmbed } from "../util/createStandardEmbed.js";
import { type Command, commands } from "../util/loadCommands.js";
export default {
data: new SlashCommandBuilder()
.setName("help")
.setDescription("Shows help and usage information for PreMiD commands")
.addStringOption(option =>
option
.setName("command")
.setDescription("The specific command to get help for")
.setAutocomplete(true),
),
autocomplete: async (interaction: AutocompleteInteraction) => {
const focusedValue = interaction.options.getFocused();
const choices = [...commands.values()]
.filter(cmd => cmd.help)
.map(cmd => ({ name: cmd.help!.name, value: cmd.help!.value }));
const filtered = choices.filter(choice => choice.name.toLowerCase().includes(focusedValue.toLowerCase()));
return interaction.respond(filtered.slice(0, 25));
},
execute: async (interaction: ChatInputCommandInteraction) => {
const command = interaction.options.getString("command");
if (command) {
const help = [...commands.values()].find(({ help }) => help?.value === command)?.help;
if (!help)
return interaction.reply({ content: "Command not found", ephemeral: true });
return interaction.reply({
embeds: [help.embed],
ephemeral: true,
});
}
const generalCommands = [];
for (const cmd of commands.values()) {
if (cmd.help) {
if (cmd.help.command)
generalCommands.push(`\`${cmd.help.command}\` - ${cmd.help.commandDescription}`);
}
}
const embed = createStandardEmbed({
title: "🛠️ PreMiD Help",
description: "PreMiD is a simple, configurable utility that allows you to show what you're doing on the web in your Discord now playing status.",
fields: [
{
name: "🔧 Commands",
value: generalCommands.join("\n") || "No commands available",
inline: false,
},
{
name: "📚 Additional Information",
value: "Use `/help <command>` for detailed information about a specific command.",
inline: false,
},
],
});
return interaction.reply({
embeds: [embed],
components: [
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setLabel("View Website").setURL("https://premid.app/").setStyle(ButtonStyle.Link),
new ButtonBuilder().setLabel("View Store").setURL("https://premid.app/store").setStyle(ButtonStyle.Link),
new ButtonBuilder().setLabel("Downloads").setURL("https://premid.app/downloads").setStyle(ButtonStyle.Link),
),
],
ephemeral: true,
});
},
} satisfies Command;

View File

@@ -0,0 +1,289 @@
import type { APIButtonComponent, AutocompleteInteraction, ChatInputCommandInteraction, ColorResolvable } from "discord.js";
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, SlashCommandBuilder } from "discord.js";
import { createStandardEmbed } from "../util/createStandardEmbed.js";
import type { Command } from "../util/loadCommands.js";
const shortInfos: {
[key: string]: {
title: string;
description: string;
emoji?: string;
image?: string;
color?: ColorResolvable;
links?: Partial<APIButtonComponent>[];
};
} = {
troubleshooting: {
title: "Troubleshooting",
emoji: "❓",
description:
"If you have problems with PreMiD, you should read our troubleshooting guide and if that doesn't help, create a new post in <#1019726199494279248>.",
links: [
{
label: "Troubleshooting Guide",
url: "https://docs.premid.app/troubleshooting",
},
],
},
modifiedClients: {
title: "Modified Clients",
color: "#FF5050",
description:
"Using a modified client is an violation of Discord's ToS and therefore you run the risk of losing your account. If you want to keep using Discord, you have to follow them and make sure you're not breaking any of the rules Discord. Even using modified clients for theming or other customizations are against Discord's ToS. If you don't believe us, read it yourself.",
links: [
{
label: "Discord's ToS",
url: "https://discordapp.com/terms",
},
{
label: "Discord's Tweet",
url: "https://twitter.com/discordapp/status/908000828690182145",
},
],
},
creatingAPresence: {
title: "Creating a Presence",
emoji: "🏗",
description:
"If you wish to add support for a service that does not have a Presence yet, you can either open an issue on GitHub to request the presence to be created or you create it yourself. If you wish to create a Presence for PreMiD you need to have basic knowledge of TypeScript. For more information and docs on how to create a Presence follow our documentation.",
links: [
{
label: "Documentation",
url: "https://docs.premid.app/dev/presence",
},
{
label: "Service Request",
url: "https://github.com/PreMiD/Presences/issues/new?assignees=&labels=Service+Request&template=service_request.yml",
},
],
},
docs: {
title: "Read the Docs",
description:
"If you have any questions regarding PreMiD, please read our documentation before creating a ticket. Presence development related queries should be redirected to <#607524579874832446>",
links: [
{
label: "Documentation",
url: "https://docs.premid.app",
},
],
},
website: {
title: "Visit Our Website",
emoji: "🌐",
description: "Press the button below to visit our website full of greatness.",
links: [
{
label: "Website",
url: "https://premid.app",
},
],
},
presenceStore: {
title: "Presence Store",
emoji: "🏪",
description: "Press the button below to visit our Presence Store full of the your favourite services!",
links: [{ label: "Presence Store", url: "https://premid.app/store" }],
},
downloadPreMiD: {
title: "Download PreMiD",
emoji: "📦",
description:
"You can download PreMiD's extension for your browser via the button below.\n**Note:** You no longer need to download any application!",
links: [{ label: "Downloads", url: "https://premid.app/downloads" }],
},
donate: {
title: "Donate",
emoji: "💵",
description:
"Want to support PreMiD's development? Great! You can do so by boosting our Discord server, which you will get a special role, or you can support us on Patreon!",
links: [
{ label: "Patreon", url: "https://patreon.com/Timeraa" },
{ label: "GitHub Sponsors", url: "https://github.com/sponsors/Timeraa" },
],
},
creatingATicket: {
title: "Creating a Support Ticket",
emoji: "🙋",
description:
"Recently, we have migrated to use Discord's new forum channels for our support system. You can now create a ticket by creating a new post in <#1019726199494279248>",
},
suggestingAPresence: {
title: "Suggesting a Presence",
emoji: "🗳",
description:
"If you'd like to suggest a presence, you can do this on our GitHub repository by creating a new issue with the Service Request template! If want to create a Presence yourself, you can find more information on our documentation",
links: [
{
label: "PreMiD Documentation",
url: "https://docs.premid.app/dev/presence",
},
{
label: "GitHub Repository",
url: "https://github.com/PreMiD/Presences",
},
{
label: "Service Request",
url: "https://github.com/PreMiD/Presences/issues/new?assignees=&labels=Service+Request&template=service_request.yml",
},
],
},
tos: {
title: "PreMiD and Discord",
emoji: "🧬",
description: "PreMiD is compliant to Discord's ToS and therefore you can use it without any risk of losing your Discord account.",
links: [
{
label: "Proof",
url: "https://twitter.com/discord/status/1233704070390669312",
},
],
},
unidentifiedDeveloper: {
title: "Allow apps from unidentified developers (macOS)",
description:
"Steps for **macOS Big Sur (11.0+)**:\n1. Right click on our installer.\n2. Click `Open` in the dropdown menu.\n3. Click `Open` in popup.\n\nSteps for **older macOS versions**:\n1. Open System Preferences.\n2. Go to the Security & Privacy tab.\n3. Click on the lock and enter your password or scan your fingerprint so you can make changes.\n4. Change the setting for 'Allow apps downloaded from' to 'App Store and identified developers' from just 'App Store'.",
},
reportingaPresenceBug: {
title: "Reporting a Presence bug",
emoji: "🐛",
description:
"If you've found an issue with a presence, it is important that you report your issue on the Presence repository so the bug is resolved within a timely fashion. You can report the bug using the Bug Report template, **ensuring you fill in the template properly**.",
links: [
{
label: "Presence Repository",
url: "https://github.com/PreMiD/Presences",
},
{
label: "Bug Report",
url: "https://github.com/PreMiD/Presences/issues/new?assignees=&labels=%F0%9F%90%9B+Bug&template=bug_report.yml&title=Service+Name+%7C+Service+URL",
},
],
},
adblockDetection: {
title: "Adblock Detection",
emoji: "🚫",
description:
"If our website has falsely detected the presence of an ad-blocker, you can simply press \"I don't want to support\" six times and you will be redirected to the download. Alternatively, you can find direct download links below.",
links: [
{
label: "Download Links",
url: "https://discord.com/channels/493130730549805057/527675240231206934/715852870062309386",
},
],
},
requestANewFeature: {
title: "Requesting a Presence feature",
emoji: "🗳",
description:
"Does a presence you use not support a crucial page or not support all the possible domains for the website? If you believe a presence should include more features, you should open an issue on the Presence Repository using the Feature Request template.",
links: [
{
label: "Template",
url: "https://github.com/PreMiD/Presences/issues/new?assignees=&labels=Feature+Request&template=feature_request.yml",
},
],
},
beta: {
title: "PreMiD Beta",
emoji: "✨",
description:
"Do you want cool new features? Want to use PreMiD with the browser version of Discord? Download the beta!",
links: [
{
label: "Beta Release Page",
url: "https://premid.app/beta",
},
],
},
frequentFixes: {
title: "Frequent fixes for Presence bugs",
emoji: "🗳",
description:
"There are some frequent fixes for presences, use the buttons to navigate to these.\n If this doesn't work, please submit your issue to <#1019726199494279248>",
links: [
{
label: "YouTube/Netflix",
url: "https://discord.com/channels/493130730549805057/527675240231206934/831995042469642251",
},
{
label: "YouTube",
url: "https://discord.com/channels/493130730549805057/527675240231206934/827037909504753704",
},
{
label: "General fix",
url: "https://discord.com/channels/493130730549805057/527675240231206934/723231955893747763",
},
],
},
};
export default {
data: new SlashCommandBuilder()
.setName("info")
.setDescription("Posts an information message")
.addStringOption(option =>
option
.setName("query")
.setDescription("The infomation message to search for")
.setAutocomplete(true),
)
.addUserOption(option =>
option
.setName("user")
.setDescription("User to mention")
.setRequired(false),
),
autocomplete: async (interaction: AutocompleteInteraction) => {
const focusedValue = interaction.options.getFocused();
const choices = Object.entries(shortInfos).map(([key, data]) => ({ name: data.title, value: key }));
const filtered = choices.filter(choice => choice.name.toLowerCase().includes(focusedValue.toLowerCase()));
return interaction.respond(filtered.slice(0, 25));
},
execute: async (interaction: ChatInputCommandInteraction) => {
const query = interaction.options.getString("query");
const user = interaction.options.getUser("user");
if (!query)
return interaction.reply({ content: "Please provide a query to search for", ephemeral: true });
const info = shortInfos[query];
if (!info)
return interaction.reply({ content: "No information found for that query", ephemeral: true });
const embed = createStandardEmbed({
title: `${info.emoji || "🔖"} ${info.title}`,
description: info.description,
});
let actionRow: ActionRowBuilder<ButtonBuilder> | undefined;
if (info.links) {
actionRow = new ActionRowBuilder<ButtonBuilder>();
for (const link of info.links) {
actionRow.addComponents(new ButtonBuilder({
style: ButtonStyle.Link,
...link,
}));
}
}
return interaction.reply({ embeds: [embed], content: user ? user.toString() : undefined, components: actionRow ? [actionRow] : undefined });
},
help: {
name: "info",
value: "info",
command: "/info <query> [user]",
commandDescription: "Posts an information message",
embed: createStandardEmbed({
title: "Command: /info",
description: "Posts an information message",
fields: [
{ name: "Usage", value: "`/info <query> [user]`", inline: true },
{ name: "Example", value: "`/info troubleshooting`\n`/info beta @User`", inline: true },
],
}),
},
} satisfies Command;

View File

@@ -0,0 +1,71 @@
import process from "node:process";
import { defu } from "defu";
import { Client, GatewayIntentBits, REST } from "discord.js";
export const processEnv = defu({
TOKEN: process.env.TOKEN,
DATABASE_URL: process.env.DATABASE_URL,
SENTRY_DSN: process.env.SENTRY_DSN,
}, {
TOKEN: "",
DATABASE_URL: "mongodb://localhost:27017/premid",
SENTRY_DSN: "",
GUILD_ID: "493130730549805057",
BETA_ROLE: "591284574823120909",
ALPHA_ROLE: "694481247564595211",
});
export const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildPresences],
presence: {
status: "online",
},
});
export const rest = new REST().setToken(processEnv.TOKEN);
export const roles = {
PROJECT_LEADER: "493135149274365975",
STAFF_COORDINATOR: "691382096878370837",
ADMINISTRATOR: "685969048399249459",
PROJECT_MANAGEMENT: "673682085608816652",
REVIEWER: "630445337143935009",
DEVELOPER: "1027665813525778436",
DESIGNER: "691386502566903850",
MODERATOR: "514546359865442304",
SUPPORT_AGENT: "566417964820070421",
MARKETING_DIRECTOR: "673681900476432387",
LOCALIZATION_MANAGER: "811262682408943616",
SOCIALS_MANAGER: "1027665964684300358",
REPRESENTATIVE: "691384256672563332",
CONTRIBUTOR: "1032759805732978708",
PATRON: "515874214750715904",
DONATOR: "502165799172309013",
BOOSTER: "585532751663333383",
PROOFREADER: "522755339448483840",
TRANSLATOR: "502148045991968788",
PRESENCE_DEV: "606222296016879722",
} as const;
export const roleColors = {
PROJECT_LEADER: "#E43725",
STAFF_COORDINATOR: "#E43725",
ADMINISTRATOR: "#E43725",
PROJECT_MANAGEMENT: "#E43725",
REVIEWER: "#3BA576",
DEVELOPER: "#3BA576",
DESIGNER: "#3BA576",
MODERATOR: "#D67118",
SUPPORT_AGENT: "#D67118",
MARKETING_DIRECTOR: "#3BA576",
LOCALIZATION_MANAGER: "#3BA576",
SOCIALS_MANAGER: "#1abc9c",
REPRESENTATIVE: "#3BA576",
CONTRIBUTOR: "#EB459E",
PATRON: "#E5472F",
DONATOR: "#FFAA33",
BOOSTER: "#F265FF",
PROOFREADER: "#00B0E6",
TRANSLATOR: "#2286D0",
PRESENCE_DEV: "#96A5E9",
} as const;

View File

@@ -0,0 +1,31 @@
import { Events } from "discord.js";
import { DiscordUsers, Presence } from "@premid/db";
import { client, roles as rolesEnv } from "../constants.js";
client.on(Events.GuildMemberAdd, async (member) => {
const [presence] = await Promise.all([
Presence.findOne({
$or: [{ "metadata.author.id": member.id }, { "metadata.contributors.id": member.id }],
}, { name: true }),
DiscordUsers.updateOne(
{ userId: member.id },
{
$set: {
avatar: member.user.avatar,
created: member.user.createdTimestamp,
discriminator: member.user.discriminator,
userId: member.id,
username: member.user.displayName ?? member.user.username,
},
},
{ upsert: true },
),
]);
//* User should have Presence Developer Role
if (presence) {
if (!member.roles.cache.has(rolesEnv.PRESENCE_DEV)) {
await member.roles.add(rolesEnv.PRESENCE_DEV, "User should have Presence Developer Role");
}
}
});

View File

@@ -0,0 +1,12 @@
import { Events } from "discord.js";
import { AlphaUsers, BetaUsers, Credits, DiscordUsers } from "@premid/db";
import { client } from "../constants.js";
client.on(Events.GuildMemberRemove, async (member) => {
await Promise.all([
BetaUsers.deleteOne({ userId: member.id }),
AlphaUsers.deleteOne({ userId: member.id }),
DiscordUsers.deleteOne({ userId: member.id }),
Credits.deleteOne({ userId: member.id }),
]);
});

View File

@@ -0,0 +1,86 @@
import { Events } from "discord.js";
import { AlphaUsers, BetaUsers, Credits, DiscordUsers } from "@premid/db";
import { client, processEnv, roleColors, roles as rolesEnv } from "../constants.js";
client.on(Events.GuildMemberUpdate, async (oldMember, newMember) => {
const highestRole = newMember.roles.cache
.filter(role => (Object.values(rolesEnv) as string[]).includes(role.id))
.sort((a, b) => b.position - a.position)
.at(0);
await Promise.all([
DiscordUsers.updateOne(
{ userId: newMember.id },
{
$set: {
avatar: newMember.user.avatar,
created: newMember.user.createdTimestamp,
discriminator: newMember.user.discriminator,
userId: newMember.id,
username: newMember.user.displayName ?? newMember.user.username,
},
},
{ upsert: true },
),
highestRole
? Credits.updateOne(
{ userId: newMember.id },
{
$set: {
userId: newMember.id,
name: newMember.user.displayName ?? newMember.user.username,
tag: newMember.user.discriminator,
avatar: newMember.user.displayAvatarURL({
extension: "png",
forceStatic: false,
}),
premium_since: newMember.premiumSince !== null ? newMember.premiumSinceTimestamp! : undefined,
role: highestRole.name,
roleId: highestRole.id,
roles: newMember.roles.cache.filter(r => r.name !== "@everyone").map(r => r.name),
roleIds: newMember.roles.cache.filter(r => r.name !== "@everyone").map(r => r.id),
roleColor: roleColors[
Object.entries(rolesEnv).find(([, id]) => id === highestRole.id)![0] as keyof typeof roleColors
],
rolePosition: highestRole.position,
status: newMember.presence?.status ?? "offline",
flags: newMember.user.flags?.toArray() ?? [],
},
},
{ upsert: true },
)
: Promise.resolve(),
]);
const roles = newMember.roles.cache.map(role => role.id);
//* User should have Alpha Role
if (roles.includes(rolesEnv.BOOSTER) || roles.includes(rolesEnv.PATRON) || newMember.roles.cache.has(processEnv.ALPHA_ROLE)) {
if (!newMember.roles.cache.has(processEnv.ALPHA_ROLE)) {
await newMember.roles.add(processEnv.ALPHA_ROLE, "User should have Alpha Role");
}
if (newMember.roles.cache.has(processEnv.BETA_ROLE)) {
await newMember.roles.remove(processEnv.BETA_ROLE, "User should have Alpha Role");
}
await Promise.all([
AlphaUsers.updateOne({ userId: newMember.id }, { $set: { userId: newMember.id } }, { upsert: true }),
BetaUsers.deleteOne({ userId: newMember.id }),
]);
return;
}
//* User should have Beta Role
const betaUser = await BetaUsers.findOne({ userId: newMember.id });
if (roles.includes(rolesEnv.DONATOR) || betaUser || newMember.roles.cache.has(processEnv.BETA_ROLE) || oldMember.roles.cache.has(processEnv.ALPHA_ROLE)) {
if (newMember.roles.cache.has(processEnv.ALPHA_ROLE)) {
await newMember.roles.remove(processEnv.ALPHA_ROLE, "User should have Beta Role");
}
if (!newMember.roles.cache.has(processEnv.BETA_ROLE)) {
await newMember.roles.add(processEnv.BETA_ROLE, "User should have Beta Role");
}
await Promise.all([
BetaUsers.updateOne({ userId: newMember.id }, { $set: { userId: newMember.id } }, { upsert: true }),
AlphaUsers.deleteOne({ userId: newMember.id }),
]);
}
});

View File

@@ -0,0 +1,42 @@
import { Events, InteractionType } from "discord.js";
import { client } from "../constants.js";
import { commands } from "../util/loadCommands.js";
import { logger } from "../util/logger.js";
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.inGuild()) {
logger.debug("Interaction received outside of a guild, ignoring.");
return;
}
if (interaction.type === InteractionType.ApplicationCommand || interaction.type === InteractionType.ApplicationCommandAutocomplete) {
const { commandName } = interaction;
logger.info(`Command "${commandName}" ${interaction.type === InteractionType.ApplicationCommandAutocomplete ? "autocomplete" : "executed"} in guild ${interaction.guildId} by user ${interaction.user.id}`);
if (!commands.has(commandName.toLowerCase()))
return;
const command = commands.get(commandName.toLowerCase())!;
if (interaction.type === InteractionType.ApplicationCommandAutocomplete) {
if (command.autocomplete) {
try {
await command.autocomplete(interaction);
logger.debug(`Autocomplete for command "${commandName}" handled successfully`);
}
catch (error) {
logger.error(`Error handling autocomplete for command "${commandName}":`, error);
}
}
return;
}
try {
await command.execute(interaction);
logger.debug(`Command "${commandName}" executed successfully`);
}
catch (error) {
logger.error(`Error executing command "${commandName}":`, error);
}
}
});

View File

@@ -0,0 +1,180 @@
import type { GuildMember } from "discord.js";
import { Events } from "discord.js";
import { AlphaUsers, BetaUsers, Credits, Presence } from "@premid/db";
import { client, processEnv, roleColors, roles as rolesEnv } from "../constants.js";
import { logger } from "../util/logger.js";
client.once(Events.ClientReady, async () => {
logger.debug("Giving roles to members");
const guild = await client.guilds.fetch(processEnv.GUILD_ID);
const members = await guild.members.fetch();
let count = 0;
for (const [, member] of members) {
count++;
const roles = member.roles.cache.map(role => role.id);
//* User should have Alpha Role
if (roles.includes(rolesEnv.BOOSTER) || roles.includes(rolesEnv.PATRON) || member.roles.cache.has(processEnv.ALPHA_ROLE)) {
if (!member.roles.cache.has(processEnv.ALPHA_ROLE)) {
await member.roles.add(processEnv.ALPHA_ROLE, "User should have Alpha Role");
}
if (member.roles.cache.has(processEnv.BETA_ROLE)) {
await member.roles.remove(processEnv.BETA_ROLE, "User should have Alpha Role");
}
await Promise.all([
AlphaUsers.updateOne({ userId: member.id }, { $set: { userId: member.id } }, { upsert: true }),
BetaUsers.deleteOne({ userId: member.id }),
]);
}
else {
const betaUser = await BetaUsers.findOne({ userId: member.id });
//* User should have Beta Role
if (roles.includes(rolesEnv.DONATOR) || betaUser || member.roles.cache.has(processEnv.BETA_ROLE)) {
if (member.roles.cache.has(processEnv.ALPHA_ROLE)) {
await member.roles.remove(processEnv.ALPHA_ROLE, "User should have Beta Role");
}
if (!member.roles.cache.has(processEnv.BETA_ROLE)) {
await member.roles.add(processEnv.BETA_ROLE, "User should have Beta Role");
}
await Promise.all([
BetaUsers.updateOne({ userId: member.id }, { $set: { userId: member.id } }, { upsert: true }),
AlphaUsers.deleteOne({ userId: member.id }),
]);
}
}
if (count % 1000 === 0) {
logger.debug(`Processed ${count}/${members.size} members`);
}
}
logger.debug(`Gave roles to ${count}/${members.size} members`);
//* Presence Developers
logger.debug("Checking for presence developers");
const presenceDevelopers = await Presence.find({}, {
"metadata.author.id": true,
"metadata.contributors.id": true,
"_id": false,
});
for (const presenceDeveloper of [
...new Set(
presenceDevelopers
.map(presence => [presence.metadata.author.id, ...(presence.metadata.contributors?.map(c => c.id) || [])])
.flat(),
),
]) {
const member = guild.members.cache.get(presenceDeveloper);
if (!member)
continue;
if (!member.roles.cache.has(rolesEnv.PRESENCE_DEV)) {
await member.roles.add(rolesEnv.PRESENCE_DEV, "User should have Presence Developer Role");
}
}
logger.debug("Checked for presence developers");
//* Update Credits
const usersToCredit = new Set<GuildMember>();
for (const roleId of Object.values(rolesEnv)) {
const role = await guild.roles.fetch(roleId);
if (!role)
continue;
for (const member of role.members.values()) {
usersToCredit.add(member);
}
}
const usersToRemove = await Credits.find({ userId: { $nin: [...usersToCredit].map(member => member.user.id) } });
await Credits.bulkWrite([
...usersToRemove.map(user => ({ deleteOne: { filter: { userId: user.userId } } })),
...[...usersToCredit].map((member) => {
const highestRole = member.roles.cache
.filter(role => (Object.values(rolesEnv) as string[]).includes(role.id))
.sort((a, b) => b.position - a.position)
.at(0)!;
const color = roleColors[
Object.entries(rolesEnv).find(([, id]) => id === highestRole.id)![0] as keyof typeof roleColors
];
return {
updateOne: {
filter: { userId: member.id },
update: {
$set: {
userId: member.id,
name: member.user.displayName ?? member.user.username,
tag: member.user.discriminator,
avatar: member.user.displayAvatarURL({
extension: "png",
forceStatic: false,
}),
premium_since: member.premiumSince !== null ? member.premiumSinceTimestamp! : undefined,
role: highestRole.name,
roleId: highestRole.id,
roles: member.roles.cache.filter(r => r.name !== "@everyone").map(r => r.name),
roleIds: member.roles.cache.filter(r => r.name !== "@everyone").map(r => r.id),
roleColor: color,
rolePosition: highestRole.position,
status: member.presence?.status ?? "offline",
flags: member.user.flags?.toArray() ?? [],
},
},
upsert: true,
},
};
}),
]);
logger.debug("Updated Credits");
//* Beta can be requested from the website so we need to periodically check for that, presence developers are external too so we need to periodically check for that
setInterval(async () => {
//* Beta Users
logger.debug("Checking for beta users");
const betaUsers = await BetaUsers.find({});
const guild = await client.guilds.fetch(processEnv.GUILD_ID);
for (const betaUser of betaUsers) {
const member = guild.members.cache.get(betaUser.userId);
if (!member)
continue;
if (!member.roles.cache.has(processEnv.BETA_ROLE)) {
await member.roles.add(processEnv.BETA_ROLE, "User should have Beta Role");
}
}
logger.debug("Checked for beta users");
//* Presence Developers
logger.debug("Checking for presence developers");
const presenceDevelopers = await Presence.find({}, {
"metadata.author.id": true,
"metadata.contributors.id": true,
"_id": false,
});
for (const presenceDeveloper of [
...new Set(
presenceDevelopers
.map(presence => [presence.metadata.author.id, ...(presence.metadata.contributors?.map(c => c.id) || [])])
.flat(),
),
]) {
const member = guild.members.cache.get(presenceDeveloper);
if (!member)
continue;
if (!member.roles.cache.has(rolesEnv.PRESENCE_DEV)) {
await member.roles.add(rolesEnv.PRESENCE_DEV, "User should have Presence Developer Role");
}
}
logger.debug("Checked for presence developers");
}, 1000 * 60 * 5);
});

View File

@@ -0,0 +1,89 @@
import process from "node:process";
import { connect } from "mongoose";
import { Routes } from "discord.js";
import * as Sentry from "@sentry/node";
import { client, processEnv, rest } from "./constants.js";
import { getActivity } from "./util/getActivity.js";
import loadCommands, { commands } from "./util/loadCommands.js";
import loadEvents from "./util/loadEvents.js";
import { logger } from "./util/logger.js";
import { updateActivityList } from "./util/activityList.js";
Sentry.init({
integrations: [
Sentry.mongooseIntegration(),
],
dsn: processEnv.SENTRY_DSN,
});
logger.info("Starting bot initialization...");
try {
await loadCommands();
logger.info("Commands loaded successfully");
}
catch (error) {
logger.error("Error loading commands:", error);
process.exit(1);
}
try {
await loadEvents();
logger.info("Events loaded successfully");
}
catch (error) {
logger.error("Error loading events:", error);
process.exit(1);
}
try {
await connect(processEnv.DATABASE_URL, { appName: "PreMiD Discord Bot" });
logger.info("Successfully connected to database");
await updateActivityList();
logger.info("Successfully updated presence list");
}
catch (error) {
logger.error("Error connecting to database:", error);
process.exit(1);
}
try {
await client.login(processEnv.TOKEN);
logger.info("Bot logged in successfully");
client.user?.setActivity(getActivity({}));
}
catch (error) {
logger.error("Failed to log in:", error);
process.exit(1);
}
client.once("ready", async (client) => {
logger.info(`Bot is ready! Logged in as ${client.user?.tag}`);
try {
//* Register guild-specific commands
await rest.put(Routes.applicationGuildCommands(client.application.id, processEnv.GUILD_ID), {
body: Array.from(commands.values()).map(({ data }) => data),
});
//* Clear global commands
await rest.put(Routes.applicationCommands(client.application.id), { body: [] });
logger.info("Successfully registered commands");
}
catch (error) {
logger.error("Failed to register commands:", error);
process.exit(1);
}
});
setInterval(async () => {
const newActivity = getActivity({
previous: client.user?.presence.activities[0]?.name,
});
client.user?.setActivity(newActivity);
logger.debug(`Updated bot activity to: ${newActivity.name}`);
}, 60000);
setInterval(() => {
updateActivityList();
}, 1000 * 60 * 5);

View File

@@ -0,0 +1,17 @@
import { Presence } from "@premid/db";
import type { PresenceMetadataCategory } from "@premid/db/Presence.js";
import { logger } from "./logger.js";
let presenceList: { service: string; category: PresenceMetadataCategory }[] = [];
export async function updateActivityList() {
presenceList = (await Presence.find({}, { metadata: { category: true, service: true } })).map(presence => ({
service: presence.metadata.service,
category: presence.metadata.category,
}));
logger.debug(`Updated presence list with ${presenceList.length} presences`);
}
export function getActivityList() {
return presenceList;
}

View File

@@ -0,0 +1,25 @@
import { type APIEmbedField, type ColorResolvable, EmbedBuilder } from "discord.js";
interface StandardEmbedOptions {
title: string;
description: string;
fields?: APIEmbedField[];
footer?: string;
color?: number | string;
}
export function createStandardEmbed({
title,
description,
fields = [],
footer = "PreMiD",
color = "Blurple",
}: StandardEmbedOptions): EmbedBuilder {
return new EmbedBuilder()
.setColor(color as ColorResolvable)
.setTitle(title)
.setDescription(description)
.addFields(fields)
.setFooter({ text: footer })
.setTimestamp();
}

View File

@@ -0,0 +1,22 @@
import type { ActivitiesOptions } from "discord.js";
import { ActivityType } from "discord.js";
import { getActivityList } from "./activityList.js";
export function getActivity({ previous }: {
previous?: string;
}): ActivitiesOptions {
const activityList = getActivityList();
const statuses = activityList.filter(status => status.service !== previous);
const selectedStatus = statuses[Math.floor(Math.random() * statuses.length)]!;
return {
type: selectedStatus.category === "music"
? ActivityType.Listening
: selectedStatus.category === "anime"
? ActivityType.Watching
: selectedStatus.category === "videos"
? ActivityType.Watching
: ActivityType.Playing,
name: selectedStatus.service,
};
}

View File

@@ -0,0 +1,39 @@
import { resolve } from "node:path";
import type { APIApplicationCommandOptionChoice, EmbedBuilder, SharedSlashCommandOptions, SlashCommandSubcommandsOnlyBuilder } from "discord.js";
import { glob } from "glob";
export interface CommandHelp extends APIApplicationCommandOptionChoice<string> {
embed: EmbedBuilder;
command?: string;
commandDescription?: string;
}
export interface Command {
data: SharedSlashCommandOptions<any> | SlashCommandSubcommandsOnlyBuilder;
execute: (interaction: any) => Promise<any>;
autocomplete?: (interaction: any) => Promise<any>;
help?: CommandHelp;
}
export const commands = new Map<string, Command>();
export default async function loadCommands() {
for (const file of await glob("*.js", { cwd: resolve(import.meta.dirname, "../commands") })) {
const imported = await import(`../commands/${file}`);
const name = typeof imported.default.data === "function" ? imported.default.data().name : imported.default.data.name;
commands.set(name.toLowerCase(), imported.default);
if (imported.default.init) {
try {
await imported.default.init();
}
catch (error) {
//* Failed to initialize command
console.error(`Failed to initialize command ${name}:`, error);
}
}
}
return commands;
}

View File

@@ -0,0 +1,8 @@
import { resolve } from "node:path";
import { glob } from "glob";
export default async function loadEvents() {
for (const file of await glob("*.js", { cwd: resolve(import.meta.dirname, "../events") })) {
import(`../events/${file}`);
}
}

View File

@@ -0,0 +1,41 @@
import process from "node:process";
import { createLogger, format, transports } from "winston";
export const logger = createLogger({
level: process.env.NODE_ENV === "production" ? (process.env.LOG_LEVEL || "info") : (process.env.LOG_LEVEL || "debug"),
format: format.combine(
format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
format.errors({ stack: true }),
format.splat(),
format.json(),
),
transports: [
new transports.Console({
format: format.combine(
format.colorize(),
format.printf(({ timestamp, level, message, ...metadata }) => {
let msg = `${timestamp} [${level}] : ${message}`;
if (Object.keys(metadata).length > 0) {
msg += `\n${JSON.stringify(metadata, null, 2)}`;
}
return msg;
}),
),
}),
new transports.File({
filename: "error.log",
level: "error",
format: format.combine(
format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
format.json(),
),
}),
new transports.File({
filename: "combined.log",
format: format.combine(
format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
format.json(),
),
}),
],
});

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist"
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"types": ["@types/node"],
"noEmit": true
},
"include": ["src"]
}

View File

@@ -1,7 +1,7 @@
{
"name": "@premid/pd",
"type": "module",
"version": "1.1.9",
"version": "1.2.2",
"private": true,
"description": "A small service to shorten image urls to get around Discord's 256 character limit",
"license": "MPL-2.0",

View File

@@ -33,7 +33,6 @@ const handler: RouteHandlerMethod = async (request, reply) => {
const existingUrl = await keyv.get(hash);
if (existingUrl) {
void reply.header("Cache-control", `public, max-age=${(30 * 60).toString()}`);
return reply.send(process.env.BASE_URL! + existingUrl);
}
@@ -42,7 +41,6 @@ const handler: RouteHandlerMethod = async (request, reply) => {
await keyv.set(hash, uniqueId, 30 * 60 * 1000);
await keyv.set(uniqueId, body, 30 * 60 * 1000);
void reply.header("Cache-control", `public, max-age=${(30 * 60).toString()}`);
return reply.send(process.env.BASE_URL! + uniqueId);
};

View File

@@ -37,7 +37,6 @@ const handler: RouteHandlerMethod = async (request, reply) => {
const existingUrl = await keyv.get(hash);
if (existingUrl) {
void reply.header("Cache-control", `public, max-age=${(30 * 60).toString()}`);
return reply.send(process.env.BASE_URL! + existingUrl);
}
@@ -48,7 +47,6 @@ const handler: RouteHandlerMethod = async (request, reply) => {
keyv.set(uniqueId, body, 30 * 60 * 1000),
]);
void reply.header("Cache-control", `public, max-age=${(30 * 60).toString()}`);
return reply.send(process.env.BASE_URL! + uniqueId);
};

View File

@@ -61,4 +61,92 @@ describe.concurrent("/create", async () => {
expect(result2.statusCode).toBe(200);
expect(result2.body).toStrictEqual(body);
});
it("should preserve file extension when URL has a valid image extension", async () => {
const result = await server.inject({
method: "GET",
url: `/create/https://www.exampl${"e".repeat(256)}.com/image.png`,
});
expect(result.statusCode).toBe(200);
expect(result.body).toStrictEqual(expect.any(String));
expect(result.body).toMatch(/\.png$/);
});
it("should preserve file extension when URL has .jpg extension", async () => {
const result = await server.inject({
method: "GET",
url: `/create/https://www.exampl${"e".repeat(256)}.com/photo.jpg`,
});
expect(result.statusCode).toBe(200);
expect(result.body).toStrictEqual(expect.any(String));
expect(result.body).toMatch(/\.jpg$/);
});
it("should preserve file extension when URL has .webp extension", async () => {
const result = await server.inject({
method: "GET",
url: `/create/https://www.exampl${"e".repeat(256)}.com/image.webp`,
});
expect(result.statusCode).toBe(200);
expect(result.body).toStrictEqual(expect.any(String));
expect(result.body).toMatch(/\.webp$/);
});
it("should preserve file extension when URL has .png with query parameters", async () => {
const result = await server.inject({
method: "GET",
url: `/create/https://www.exampl${"e".repeat(256)}.com/image.png?ref=example`,
});
expect(result.statusCode).toBe(200);
expect(result.body).toStrictEqual(expect.any(String));
expect(result.body).toMatch(/\.png$/);
});
it("should preserve file extension when URL has .gif with complex query parameters", async () => {
const result = await server.inject({
method: "GET",
url: `/create/https://www.exampl${"e".repeat(256)}.com/animated.gif?size=large&quality=high`,
});
expect(result.statusCode).toBe(200);
expect(result.body).toStrictEqual(expect.any(String));
expect(result.body).toMatch(/\.gif$/);
});
it("should not preserve file extension when URL has invalid extension", async () => {
const result = await server.inject({
method: "GET",
url: `/create/https://www.exampl${"e".repeat(256)}.com/document.pdf`,
});
expect(result.statusCode).toBe(200);
expect(result.body).toStrictEqual(expect.any(String));
expect(result.body).not.toMatch(/\.pdf$/);
});
it("should work normally when URL has no file extension", async () => {
const result = await server.inject({
method: "GET",
url: `/create/https://www.exampl${"e".repeat(256)}.com/page`,
});
expect(result.statusCode).toBe(200);
expect(result.body).toStrictEqual(expect.any(String));
expect(result.body).not.toMatch(/\.\w+$/);
});
it("should handle case-insensitive extensions", async () => {
const result = await server.inject({
method: "GET",
url: `/create/https://www.exampl${"e".repeat(256)}.com/image.PNG`,
});
expect(result.statusCode).toBe(200);
expect(result.body).toStrictEqual(expect.any(String));
expect(result.body).toMatch(/\.png$/); //* Should be lowercase in result
});
});

View File

@@ -19,17 +19,25 @@ const handler: RouteHandlerMethod = async (request, reply) => {
if (!["http:", "https:"].includes(urlObject.protocol))
return reply.status(400).send("Invalid URL");
//* Extract file extension from URL pathname
const pathname = urlObject.pathname;
const extensionMatch = pathname.match(/\.([^./]+)$/);
const extension = extensionMatch?.[1]?.toLowerCase() ?? null;
//* Check if extension is in allowed list
const allowedExtensions = ["png", "jpeg", "jpg", "gif", "webp"];
const hasValidExtension = extension && allowedExtensions.includes(extension);
const hash = crypto.createHash("sha256").update(url).digest("hex");
const existingShortenedUrl = await keyv.get(hash);
void reply.header("Cache-control", "public, max-age=1800");
if (existingShortenedUrl) {
await Promise.all([keyv.set(hash, existingShortenedUrl, 1800), keyv.set(existingShortenedUrl, url, 1800)]);
return reply.send(process.env.BASE_URL! + existingShortenedUrl);
}
const uniqueId = nanoid(10);
//* Create unique ID with extension if valid, otherwise without extension
const uniqueId = hasValidExtension ? `${nanoid(10)}.${extension}` : nanoid(10);
await Promise.all([keyv.set(hash, uniqueId, 1800), keyv.set(uniqueId, url, 1800)]);

View File

@@ -98,4 +98,31 @@ describe("getFullLink", async () => {
expect(result.headers.get("content-type")).toBe("image/png");
expect(Buffer.from(await result.arrayBuffer())).toStrictEqual(imageBuffer);
});
it("should fetch and return PNG image instead of redirecting for URLs with .png extension", async () => {
const testUrl = `https://cdn.rcd.gg/PreMiD/resources/reading.png?v=${"a".repeat(250)}`;
const { body } = await server.inject({
url: `/create/${testUrl}`,
});
expect(body).toStrictEqual(expect.any(String));
vi.spyOn(isInCIDRRange, "default").mockReturnValueOnce(true);
const result = await fetch(`${url}${body}`, {
headers: {
"cf-connecting-ip": "",
},
});
expect(result.status).toBe(200);
expect(result.headers.get("content-type")).toMatch(/^image\//);
//* Should return image data, not redirect
expect(result.headers.get("location")).toBeNull();
//* Verify we got actual image data
const imageBuffer = await result.arrayBuffer();
expect(imageBuffer.byteLength).toBeGreaterThan(0);
});
});

View File

@@ -32,20 +32,50 @@ const handler: RouteHandlerMethod = async (request, reply) => {
const hash = crypto.createHash("sha256").update(url).digest("hex");
await Promise.all([keyv.set(hash, id, 30 * 60 * 1000), keyv.set(id, url, 30 * 60 * 1000)]);
void reply.header("Cache-control", "public, max-age=1800");
//* If it is not a base64 string, redirect to it
if (!url.startsWith("data:image"))
return reply.redirect(url);
//* If it is a base64 string, decode and return the image
if (url.startsWith("data:image")) {
const image = Buffer.from(
url.replace(/^data:image\/\w+;base64,/, ""),
"base64",
);
const image = Buffer.from(
url.replace(/^data:image\/\w+;base64,/, ""),
"base64",
);
const mime = url.split(";")[0]!.split(":")[1]!;
const mime = url.split(";")[0]!.split(":")[1]!;
return reply.type(mime).send(image);
}
return reply.type(mime).send(image);
//* Check if URL has a valid image extension
const urlObject = new URL(url);
const pathname = urlObject.pathname;
const extensionMatch = pathname.match(/\.([^./]+)$/);
const extension = extensionMatch?.[1]?.toLowerCase() ?? null;
const allowedExtensions = ["png", "jpeg", "jpg", "gif", "webp"];
const hasValidImageExtension = extension && allowedExtensions.includes(extension);
//* If URL has valid image extension, fetch and return the image
if (hasValidImageExtension) {
try {
const response = await fetch(url);
if (!response.ok) {
return reply.code(404).send("Image not found");
}
const contentType = response.headers.get("content-type") || `image/${extension}`;
const imageBuffer = Buffer.from(await response.arrayBuffer());
return reply.type(contentType).send(imageBuffer);
}
catch {
//* If fetch fails, fall back to redirect
return reply.redirect(url);
}
}
//* For all other URLs, redirect to them
return reply.redirect(url);
};
export default handler;

View File

@@ -1,7 +1,7 @@
{
"name": "@premid/schema-server",
"type": "module",
"version": "1.0.4",
"version": "1.0.10",
"private": true,
"description": "A small service to serve the JSON schemas for PreMiD",
"license": "MPL-2.0",

View File

@@ -0,0 +1,252 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://schemas.premid.app/metadata/1.12",
"title": "Metadata",
"type": "object",
"description": "Metadata that describes a presence.",
"definitions": {
"user": {
"type": "object",
"description": "User information.",
"properties": {
"name": {
"type": "string",
"description": "The name of the user."
},
"id": {
"type": "string",
"description": "The Discord snowflake of the user.",
"pattern": "^\\d+$"
}
},
"additionalProperties": false,
"required": [
"name",
"id"
]
}
},
"properties": {
"$schema": {
"$comment": "This is required otherwise the schema will fail itself when it is applied to a document via $schema. This is optional so that validators that use this schema don't fail if the metadata doesn't have the $schema property.",
"type": "string",
"description": "The metadata schema URL."
},
"author": {
"$ref": "#/definitions/user",
"description": "The author of this presence."
},
"contributors": {
"type": "array",
"description": "Any extra contributors to this presence.",
"items": {
"$ref": "#/definitions/user"
}
},
"service": {
"type": "string",
"description": "The service this presence is for."
},
"altnames": {
"type": "array",
"description": "Alternative names for the service.",
"items": {
"type": "string",
"description": "An alternative name."
},
"minItems": 1
},
"description": {
"type": "object",
"description": "A description of the presence in multiple languages.",
"propertyNames": {
"type": "string",
"description": "The language key. The key must be languagecode(_REGIONCODE).",
"pattern": "^[a-z]{2}(?:_(?:[A-Z]{2}|[0-9]{1,3}))?$"
},
"patternProperties": {
"^[a-z]{2}(?:_(?:[A-Z]{2}|[0-9]{1,3}))?$": {
"type": "string",
"description": "The description of the presence in the key's language."
}
},
"additionalProperties": false,
"required": [
"en"
]
},
"url": {
"type": [
"string",
"array"
],
"description": "The service's website URL, or an array of URLs. Protocols should not be added.",
"pattern": "^(([a-z0-9-]+\\.)*[0-9a-z_-]+(\\.[a-z]+)+|(\\d{1,3}\\.){3}\\d{1,3}|localhost)$",
"items": {
"type": "string",
"description": "One of the service's website URLs.",
"pattern": "^(([a-z0-9-]+\\.)*[0-9a-z_-]+(\\.[a-z]+)+|(\\d{1,3}\\.){3}\\d{1,3}|localhost)$"
},
"minItems": 2
},
"version": {
"type": "string",
"description": "The SemVer version of the presence. Must just be major.minor.patch.",
"pattern": "^\\d+\\.\\d+\\.\\d+$"
},
"apiVersion": {
"type": "integer",
"description": "The Presence System version this Presence supports.",
"minimum": 1,
"maximum": 2
},
"logo": {
"type": "string",
"description": "The logo of the service this presence is for.",
"pattern": "^https?://.+\\.(png|jpe?g|gif|webp)$"
},
"thumbnail": {
"type": "string",
"description": "A thumbnail of the service this presence is for.",
"pattern": "^https?://.+\\.(png|jpe?g|gif|webp)$"
},
"color": {
"type": "string",
"description": "The theme color of the service this presence is for. Must be either a 6 digit or a 3 digit hex code.",
"pattern": "^#([A-Fa-f0-9]{3}){1,2}$"
},
"tags": {
"type": [
"array"
],
"description": "The tags for the presence.",
"items": {
"type": "string",
"description": "A tag.",
"pattern": "^[^A-Z\\s!\"#$%&'()*+,./:;<=>?@\\[\\\\\\]^_`{|}~]+$"
},
"minItems": 1
},
"category": {
"type": "string",
"description": "The category the presence falls under.",
"enum": [
"anime",
"games",
"music",
"socials",
"videos",
"other"
]
},
"iframe": {
"type": "boolean",
"description": "Whether or not the presence should run in IFrames."
},
"readLogs": {
"type": "boolean",
"description": "Whether or not the extension should be reading logs."
},
"regExp": {
"type": "string",
"description": "A regular expression used to match URLs for the presence to inject into."
},
"iFrameRegExp": {
"type": "string",
"description": "A regular expression used to match IFrames for the presence to inject into."
},
"button": {
"type": "boolean",
"description": "Controls whether the presence is automatically added when the extension is installed. For partner presences only."
},
"warning": {
"type": "boolean",
"description": "Shows a warning saying that it requires additional steps for the presence to function correctly."
},
"settings": {
"type": "array",
"description": "An array of settings the user can change in the presence.",
"items": {
"type": "object",
"description": "A setting.",
"properties": {
"id": {
"type": "string",
"description": "The ID of the setting."
},
"title": {
"type": "string",
"description": "The title of the setting. Required only if `multiLanguage` is disabled."
},
"icon": {
"type": "string",
"description": "The icon of the setting. Required only if `multiLanguage` is disabled.",
"pattern": "^fa([bsdrlt]|([-](brands|solid|duotone|regular|light|thin))) fa-[0-9a-z-]+$"
},
"if": {
"type": "object",
"description": "Restrict showing this setting if another setting is the defined value.",
"propertyNames": {
"type": "string",
"description": "The ID of the setting."
},
"patternProperties": {
"": {
"type": [
"string",
"number",
"boolean"
],
"description": "The value of the setting."
}
},
"additionalProperties": false
},
"placeholder": {
"type": "string",
"description": "The placeholder for settings that require input. Shown when the input is empty."
},
"value": {
"type": [
"string",
"number",
"boolean"
],
"description": "The default value of the setting. Not compatible with `values`."
},
"values": {
"type": "array",
"description": "The default values of the setting. Not compatible with `value`.",
"items": {
"type": [
"string",
"number",
"boolean"
],
"description": "The value of the setting."
}
},
"multiLanguage": {
"type": "boolean",
"description": "When true, strings from the `general.json` file are available for use, plus the <service>.json file. False is not allowed."
}
},
"additionalProperties": false
}
}
},
"additionalProperties": false,
"required": [
"author",
"service",
"description",
"url",
"version",
"apiVersion",
"logo",
"thumbnail",
"color",
"tags",
"category"
]
}

View File

@@ -0,0 +1,248 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://schemas.premid.app/metadata/1.13",
"title": "Metadata",
"type": "object",
"description": "Metadata that describes a activity.",
"definitions": {
"user": {
"type": "object",
"description": "User information.",
"properties": {
"name": {
"type": "string",
"description": "The name of the user."
},
"id": {
"type": "string",
"description": "The Discord snowflake of the user.",
"pattern": "^\\d+$"
}
},
"additionalProperties": false,
"required": [
"name",
"id"
]
}
},
"properties": {
"$schema": {
"$comment": "This is required otherwise the schema will fail itself when it is applied to a document via $schema. This is optional so that validators that use this schema don't fail if the metadata doesn't have the $schema property.",
"type": "string",
"description": "The metadata schema URL."
},
"author": {
"$ref": "#/definitions/user",
"description": "The author of this activity."
},
"contributors": {
"type": "array",
"description": "Any extra contributors to this activity.",
"items": {
"$ref": "#/definitions/user"
}
},
"service": {
"type": "string",
"description": "The service this activity is for."
},
"altnames": {
"type": "array",
"description": "Alternative names for the service.",
"items": {
"type": "string",
"description": "An alternative name."
},
"minItems": 1
},
"description": {
"type": "object",
"description": "A description of the activity in multiple languages.",
"propertyNames": {
"type": "string",
"description": "The language key. The key must be languagecode(-regioncode).",
"pattern": "^[a-z]{2,3}(?:-(?:[a-z]{2}|[0-9]{1,3}))?$"
},
"patternProperties": {
"^[a-z]{2,3}(?:-(?:[a-z]{2}|[0-9]{1,3}))?$": {
"type": "string",
"description": "The description of the activity in the key's language."
}
},
"additionalProperties": false,
"required": [
"en"
]
},
"url": {
"type": [
"string",
"array"
],
"description": "The service's website URL, or an array of URLs. Protocols should not be added.",
"pattern": "^(([a-z0-9-]+\\.)*[0-9a-z_-]+(\\.[a-z]+)+|(\\d{1,3}\\.){3}\\d{1,3}|localhost)$",
"items": {
"type": "string",
"description": "One of the service's website URLs.",
"pattern": "^(([a-z0-9-]+\\.)*[0-9a-z_-]+(\\.[a-z]+)+|(\\d{1,3}\\.){3}\\d{1,3}|localhost)$"
},
"minItems": 2
},
"version": {
"type": "string",
"description": "The SemVer version of the activity. Must just be major.minor.patch.",
"pattern": "^\\d+\\.\\d+\\.\\d+$"
},
"apiVersion": {
"type": "integer",
"description": "The Activity System version this activity supports.",
"minimum": 1,
"maximum": 2
},
"logo": {
"type": "string",
"description": "The logo of the service this activity is for.",
"pattern": "^https?://.+\\.(png|jpe?g|gif|webp)$"
},
"thumbnail": {
"type": "string",
"description": "A thumbnail of the service this activity is for.",
"pattern": "^https?://.+\\.(png|jpe?g|gif|webp)$"
},
"color": {
"type": "string",
"description": "The theme color of the service this activity is for. Must be either a 6 digit or a 3 digit hex code.",
"pattern": "^#([A-Fa-f0-9]{3}){1,2}$"
},
"tags": {
"type": [
"array"
],
"description": "The tags for the activity.",
"items": {
"type": "string",
"description": "A tag.",
"pattern": "^[^A-Z\\s!\"#$%&'()*+,./:;<=>?@\\[\\\\\\]^_`{|}~]+$"
},
"minItems": 1
},
"category": {
"type": "string",
"description": "The category the activity falls under.",
"enum": [
"anime",
"games",
"music",
"socials",
"videos",
"other"
]
},
"iframe": {
"type": "boolean",
"description": "Whether or not the activity should run in IFrames."
},
"readLogs": {
"type": "boolean",
"description": "Whether or not the extension should be reading logs."
},
"regExp": {
"type": "string",
"description": "A regular expression used to match URLs for the activity to inject into."
},
"iFrameRegExp": {
"type": "string",
"description": "A regular expression used to match IFrames for the activity to inject into."
},
"settings": {
"type": "array",
"description": "An array of settings the user can change in the activity.",
"items": {
"type": "object",
"description": "A setting.",
"properties": {
"id": {
"type": "string",
"description": "The ID of the setting."
},
"title": {
"type": "string",
"description": "The title of the setting. Required only if `multiLanguage` is disabled."
},
"icon": {
"type": "string",
"description": "The icon of the setting. Required only if `multiLanguage` is disabled.",
"pattern": "^fa([bsdrlt]|([-](brands|solid|duotone|regular|light|thin))) fa-[0-9a-z-]+$"
},
"if": {
"type": "object",
"description": "Restrict showing this setting if another setting is the defined value.",
"propertyNames": {
"type": "string",
"description": "The ID of the setting."
},
"patternProperties": {
"": {
"type": [
"string",
"number",
"boolean"
],
"description": "The value of the setting."
}
},
"additionalProperties": false
},
"placeholder": {
"type": "string",
"description": "The placeholder for settings that require input. Shown when the input is empty."
},
"value": {
"type": [
"string",
"number",
"boolean"
],
"description": "The default value of the setting. Not compatible with `values`."
},
"values": {
"type": "array",
"description": "The default values of the setting. Not compatible with `value`.",
"items": {
"type": [
"string",
"number",
"boolean"
],
"description": "The value of the setting."
}
},
"multiLanguage": {
"type": "boolean",
"description": "When true, strings from the `general.json` file are available for use, plus the <service>.json file. False is not allowed."
}
},
"additionalProperties": false
}
},
"mobile": {
"type": "boolean",
"description": "Whether or not the activity has support for mobile devices."
}
},
"additionalProperties": false,
"required": [
"author",
"service",
"description",
"url",
"version",
"apiVersion",
"logo",
"thumbnail",
"color",
"tags",
"category"
]
}

View File

@@ -0,0 +1,252 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://schemas.premid.app/metadata/1.14",
"title": "Metadata",
"type": "object",
"description": "Metadata that describes a activity.",
"definitions": {
"user": {
"type": "object",
"description": "User information.",
"properties": {
"name": {
"type": "string",
"description": "The name of the user."
},
"id": {
"type": "string",
"description": "The Discord snowflake of the user.",
"pattern": "^\\d+$"
}
},
"additionalProperties": false,
"required": [
"name",
"id"
]
}
},
"properties": {
"$schema": {
"$comment": "This is required otherwise the schema will fail itself when it is applied to a document via $schema. This is optional so that validators that use this schema don't fail if the metadata doesn't have the $schema property.",
"type": "string",
"description": "The metadata schema URL."
},
"author": {
"$ref": "#/definitions/user",
"description": "The author of this activity."
},
"contributors": {
"type": "array",
"description": "Any extra contributors to this activity.",
"items": {
"$ref": "#/definitions/user"
}
},
"service": {
"type": "string",
"description": "The service this activity is for."
},
"altnames": {
"type": "array",
"description": "Alternative names for the service.",
"items": {
"type": "string",
"description": "An alternative name."
},
"minItems": 1
},
"description": {
"type": "object",
"description": "A description of the activity in multiple languages.",
"propertyNames": {
"type": "string",
"description": "The language key. The key must be languagecode(-regioncode).",
"pattern": "^[a-z]{2,3}(?:-(?:[a-z]{2}|[0-9]{1,3}))?$"
},
"patternProperties": {
"^[a-z]{2,3}(?:-(?:[a-z]{2}|[0-9]{1,3}))?$": {
"type": "string",
"description": "The description of the activity in the key's language."
}
},
"additionalProperties": false,
"required": [
"en"
]
},
"url": {
"type": [
"string",
"array"
],
"description": "The service's website URL, or an array of URLs. Protocols should not be added.",
"pattern": "^(([a-z0-9-]+\\.)*[0-9a-z_-]+(\\.[a-z]+)+|(\\d{1,3}\\.){3}\\d{1,3}|localhost)$",
"items": {
"type": "string",
"description": "One of the service's website URLs.",
"pattern": "^(([a-z0-9-]+\\.)*[0-9a-z_-]+(\\.[a-z]+)+|(\\d{1,3}\\.){3}\\d{1,3}|localhost)$"
},
"minItems": 2
},
"version": {
"type": "string",
"description": "The SemVer version of the activity. Must just be major.minor.patch.",
"pattern": "^\\d+\\.\\d+\\.\\d+$"
},
"apiVersion": {
"type": "integer",
"description": "The Activity System version this activity supports.",
"minimum": 1,
"maximum": 2
},
"logo": {
"type": "string",
"description": "The logo of the service this activity is for.",
"pattern": "^https?://.+\\.(png|jpe?g|gif|webp)$"
},
"thumbnail": {
"type": "string",
"description": "A thumbnail of the service this activity is for.",
"pattern": "^https?://.+\\.(png|jpe?g|gif|webp)$"
},
"color": {
"type": "string",
"description": "The theme color of the service this activity is for. Must be either a 6 digit or a 3 digit hex code.",
"pattern": "^#([A-Fa-f0-9]{3}){1,2}$"
},
"tags": {
"type": [
"array"
],
"description": "The tags for the activity.",
"items": {
"type": "string",
"description": "A tag.",
"pattern": "^[^A-Z\\s!\"#$%&'()*+,./:;<=>?@\\[\\\\\\]^_`{|}~]+$"
},
"minItems": 1
},
"category": {
"type": "string",
"description": "The category the activity falls under.",
"enum": [
"anime",
"games",
"music",
"socials",
"videos",
"other"
]
},
"iframe": {
"type": "boolean",
"description": "Whether or not the activity should run in IFrames."
},
"readLogs": {
"type": "boolean",
"description": "Whether or not the extension should be reading logs."
},
"regExp": {
"type": "string",
"description": "A regular expression used to match URLs for the activity to inject into."
},
"iFrameRegExp": {
"type": "string",
"description": "A regular expression used to match IFrames for the activity to inject into."
},
"settings": {
"type": "array",
"description": "An array of settings the user can change in the activity.",
"items": {
"type": "object",
"description": "A setting.",
"properties": {
"id": {
"type": "string",
"description": "The ID of the setting."
},
"title": {
"type": "string",
"description": "The title of the setting. Required only if `multiLanguage` is disabled."
},
"description": {
"type": "string",
"description": "The description of the setting. Only applicable if `multiLanguage` is disabled."
},
"icon": {
"type": "string",
"description": "The icon of the setting. Required only if `multiLanguage` is disabled.",
"pattern": "^fa([bsdrlt]|([-](brands|solid|duotone|regular|light|thin))) fa-[0-9a-z-]+$"
},
"if": {
"type": "object",
"description": "Restrict showing this setting if another setting is the defined value.",
"propertyNames": {
"type": "string",
"description": "The ID of the setting."
},
"patternProperties": {
"": {
"type": [
"string",
"number",
"boolean"
],
"description": "The value of the setting."
}
},
"additionalProperties": false
},
"placeholder": {
"type": "string",
"description": "The placeholder for settings that require input. Shown when the input is empty."
},
"value": {
"type": [
"string",
"number",
"boolean"
],
"description": "The default value of the setting. Not compatible with `values`."
},
"values": {
"type": "array",
"description": "The default values of the setting. Not compatible with `value`.",
"items": {
"type": [
"string",
"number",
"boolean"
],
"description": "The value of the setting."
}
},
"multiLanguage": {
"type": "boolean",
"description": "When true, strings from the `general.json` file are available for use, plus the <service>.json file. False is not allowed."
}
},
"additionalProperties": false
}
},
"mobile": {
"type": "boolean",
"description": "Whether or not the activity has support for mobile devices."
}
},
"additionalProperties": false,
"required": [
"author",
"service",
"description",
"url",
"version",
"apiVersion",
"logo",
"thumbnail",
"color",
"tags",
"category"
]
}

View File

@@ -0,0 +1,256 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://schemas.premid.app/metadata/1.15",
"title": "Metadata",
"type": "object",
"description": "Metadata that describes a activity.",
"definitions": {
"user": {
"type": "object",
"description": "User information.",
"properties": {
"name": {
"type": "string",
"description": "The name of the user."
},
"id": {
"type": "string",
"description": "The Discord snowflake of the user.",
"pattern": "^\\d+$"
}
},
"additionalProperties": false,
"required": [
"name",
"id"
]
}
},
"properties": {
"$schema": {
"$comment": "This is required otherwise the schema will fail itself when it is applied to a document via $schema. This is optional so that validators that use this schema don't fail if the metadata doesn't have the $schema property.",
"type": "string",
"description": "The metadata schema URL."
},
"author": {
"$ref": "#/definitions/user",
"description": "The author of this activity."
},
"contributors": {
"type": "array",
"description": "Any extra contributors to this activity.",
"items": {
"$ref": "#/definitions/user"
}
},
"service": {
"type": "string",
"description": "The service this activity is for."
},
"altnames": {
"type": "array",
"description": "Alternative names for the service.",
"items": {
"type": "string",
"description": "An alternative name."
},
"minItems": 1
},
"description": {
"type": "object",
"description": "A description of the activity in multiple languages.",
"propertyNames": {
"type": "string",
"description": "The language key. The key must be languagecode(-regioncode).",
"pattern": "^[a-z]{2,3}(?:-(?:[a-z]{2}|[0-9]{1,3}))?$"
},
"patternProperties": {
"^[a-z]{2,3}(?:-(?:[a-z]{2}|[0-9]{1,3}))?$": {
"type": "string",
"description": "The description of the activity in the key's language."
}
},
"additionalProperties": false,
"required": [
"en"
]
},
"url": {
"type": [
"string",
"array"
],
"description": "The service's website URL, or an array of URLs. Protocols should not be added.",
"pattern": "^(([a-z0-9-]+\\.)*[0-9a-z_-]+(\\.[a-z]+)+|(\\d{1,3}\\.){3}\\d{1,3}|localhost)$",
"items": {
"type": "string",
"description": "One of the service's website URLs.",
"pattern": "^(([a-z0-9-]+\\.)*[0-9a-z_-]+(\\.[a-z]+)+|(\\d{1,3}\\.){3}\\d{1,3}|localhost)$"
},
"minItems": 2
},
"version": {
"type": "string",
"description": "The SemVer version of the activity. Must just be major.minor.patch.",
"pattern": "^\\d+\\.\\d+\\.\\d+$"
},
"apiVersion": {
"type": "integer",
"description": "The Activity System version this activity supports.",
"minimum": 1,
"maximum": 2
},
"logo": {
"type": "string",
"description": "The logo of the service this activity is for.",
"pattern": "^https?://.+\\.(png|jpe?g|gif|webp)$"
},
"thumbnail": {
"type": "string",
"description": "A thumbnail of the service this activity is for.",
"pattern": "^https?://.+\\.(png|jpe?g|gif|webp)$"
},
"color": {
"type": "string",
"description": "The theme color of the service this activity is for. Must be either a 6 digit or a 3 digit hex code.",
"pattern": "^#([A-Fa-f0-9]{3}){1,2}$"
},
"tags": {
"type": [
"array"
],
"description": "The tags for the activity.",
"items": {
"type": "string",
"description": "A tag.",
"pattern": "^[^A-Z\\s!\"#$%&'()*+,./:;<=>?@\\[\\\\\\]^_`{|}~]+$"
},
"minItems": 1
},
"category": {
"type": "string",
"description": "The category the activity falls under.",
"enum": [
"anime",
"games",
"music",
"socials",
"videos",
"other"
]
},
"iframe": {
"type": "boolean",
"description": "Whether or not the activity should run in IFrames."
},
"readLogs": {
"type": "boolean",
"description": "Whether or not the extension should be reading logs."
},
"regExp": {
"type": "string",
"description": "A regular expression used to match URLs for the activity to inject into."
},
"iFrameRegExp": {
"type": "string",
"description": "A regular expression used to match IFrames for the activity to inject into."
},
"allowURLOverrides": {
"type": "boolean",
"description": "Whether or not the activity should allow URL overrides."
},
"settings": {
"type": "array",
"description": "An array of settings the user can change in the activity.",
"items": {
"type": "object",
"description": "A setting.",
"properties": {
"id": {
"type": "string",
"description": "The ID of the setting."
},
"title": {
"type": "string",
"description": "The title of the setting. Required only if `multiLanguage` is disabled."
},
"description": {
"type": "string",
"description": "The description of the setting. Only applicable if `multiLanguage` is disabled."
},
"icon": {
"type": "string",
"description": "The icon of the setting. Required only if `multiLanguage` is disabled.",
"pattern": "^fa([bsdrlt]|([-](brands|solid|duotone|regular|light|thin))) fa-[0-9a-z-]+$"
},
"if": {
"type": "object",
"description": "Restrict showing this setting if another setting is the defined value.",
"propertyNames": {
"type": "string",
"description": "The ID of the setting."
},
"patternProperties": {
"": {
"type": [
"string",
"number",
"boolean"
],
"description": "The value of the setting."
}
},
"additionalProperties": false
},
"placeholder": {
"type": "string",
"description": "The placeholder for settings that require input. Shown when the input is empty."
},
"value": {
"type": [
"string",
"number",
"boolean"
],
"description": "The default value of the setting. Not compatible with `values`."
},
"values": {
"type": "array",
"description": "The default values of the setting. Not compatible with `value`.",
"items": {
"type": [
"string",
"number",
"boolean"
],
"description": "The value of the setting."
}
},
"multiLanguage": {
"type": "boolean",
"description": "When true, strings from the `general.json` file are available for use, plus the <service>.json file. False is not allowed."
}
},
"additionalProperties": false
}
},
"mobile": {
"type": "boolean",
"description": "Whether or not the activity has support for mobile devices."
}
},
"additionalProperties": false,
"required": [
"author",
"service",
"description",
"url",
"version",
"apiVersion",
"logo",
"thumbnail",
"color",
"tags",
"category"
]
}

View File

@@ -15,6 +15,8 @@ const staff = computed(() => {
"514546359865442304",
//* Support
"566417964820070421",
//* Social Media Manager
"1027665964684300358",
].includes(item?.user?.roleId || ""),
)
.sort(sortContributors) || []

1
commitlint.config.js Normal file
View File

@@ -0,0 +1 @@
export default { extends: ["@commitlint/config-conventional"] };

View File

@@ -38,10 +38,5 @@
"prettier": "^3.2.5",
"typescript": "^5.5.4",
"vitest": "^2.0.2"
},
"pnpm": {
"patchedDependencies": {
"ip-location-api@1.0.0": "patches/ip-location-api@1.0.0.patch"
}
}
}

View File

@@ -13,6 +13,6 @@
"lib"
],
"dependencies": {
"mongoose": "^8.2.0"
"mongoose": "^8.6.3"
}
}

View File

@@ -0,0 +1,11 @@
import mongoose, { Schema } from "mongoose";
export interface AlphaUsersSchema {
userId: string;
}
const alphaUsersSchema = new Schema<AlphaUsersSchema>({
userId: { required: true, type: String },
});
export default mongoose.model("AlphaUsers", alphaUsersSchema, "alphaUsers");

View File

@@ -0,0 +1,11 @@
import mongoose, { Schema } from "mongoose";
export interface BetaUsersSchema {
userId: string;
}
const betaUsersSchema = new Schema<BetaUsersSchema>({
userId: { required: true, type: String },
});
export default mongoose.model("BetaUsers", betaUsersSchema, "betaUsers");

View File

@@ -0,0 +1,35 @@
import mongoose, { Schema } from "mongoose";
export interface CreditsSchema {
userId: string;
avatar: string;
name: string;
premium_since: number;
role: string;
roleColor: string;
roleId: string;
roleIds: string[];
rolePosition: number;
roles: string[];
status: string;
tag: string;
flags: string[];
}
const creditsSchema = new Schema<CreditsSchema>({
userId: { required: true, type: String },
avatar: { required: true, type: String },
name: { required: true, type: String },
premium_since: { required: true, type: Number },
role: { required: true, type: String },
roleColor: { required: true, type: String },
roleId: { required: true, type: String },
roleIds: { required: true, type: [String] },
rolePosition: { required: true, type: Number },
roles: { required: true, type: [String] },
status: { required: true, type: String },
tag: { required: true, type: String },
flags: { required: true, type: [String] },
});
export default mongoose.model("Credits", creditsSchema, "credits");

View File

@@ -0,0 +1,15 @@
import mongoose, { Schema } from "mongoose";
export interface DiscordUsersSchema {
userId: string;
username: string;
avatar: string;
discriminator: string;
created: number;
}
const discordUsersSchema = new Schema<DiscordUsersSchema>({
userId: { required: true, type: String },
});
export default mongoose.model("DiscordUsers", discordUsersSchema, "discordUsers");

View File

@@ -12,6 +12,7 @@ export interface PresenceSchema {
export interface PresenceMetadata {
$schema: string;
apiVersion: number;
altnames?: string[];
author: PresenceMetadataContributor;
category: PresenceMetadataCategory;
@@ -61,15 +62,15 @@ const PresenceMetadataSettingSchema = new Schema<PresenceMetadataSetting>({
placeholder: { type: String },
title: { type: String },
value: Schema.Types.Mixed,
values: { type: [String] },
values: { type: [Schema.Types.Mixed], default: undefined },
});
const PresenceMetadataSchema = new Schema<PresenceMetadata>({
$schema: { required: true, type: String },
altnames: { type: [String] },
altnames: { type: [String], default: undefined },
author: { required: true, type: PresenceMetadataContributorSchema },
category: { required: true, type: String },
color: { required: true, type: String },
contributors: { type: [PresenceMetadataContributorSchema] },
contributors: { type: [PresenceMetadataContributorSchema], default: undefined },
description: { required: true, type: Schema.Types.Mixed },
iFrameRegExp: { type: String },
iframe: { type: Boolean },
@@ -77,11 +78,12 @@ const PresenceMetadataSchema = new Schema<PresenceMetadata>({
readLogs: { type: Boolean },
regExp: { type: String },
service: { required: true, type: String },
settings: { type: [PresenceMetadataSettingSchema] },
settings: { type: [PresenceMetadataSettingSchema], default: undefined },
tags: { required: true, type: [String] },
thumbnail: { required: true, type: String },
url: { required: true, type: [String] },
url: { required: true, type: Schema.Types.Mixed },
version: { required: true, type: String },
apiVersion: { required: true, type: Number },
});
const presenceSchema = new Schema<PresenceSchema>({
folderName: { required: true, type: String },
@@ -93,4 +95,4 @@ const presenceSchema = new Schema<PresenceSchema>({
url: { required: true, type: String },
});
export default mongoose.model("Presence", presenceSchema);
export default mongoose.model("Presence", presenceSchema, "presences");

View File

@@ -1 +1,5 @@
export { default as AlphaUsers } from "./AlphaUsers.js";
export { default as BetaUsers } from "./BetaUsers.js";
export { default as Credits } from "./Credits.js";
export { default as DiscordUsers } from "./DiscordUsers.js";
export { default as Presence } from "./Presence.js";

View File

@@ -1,62 +0,0 @@
diff --git a/browser/country/README.md b/browser/country/README.md
deleted file mode 100644
index ac8fc934b4998f2a2cb7a92bf68bbdadd9e3d36d..0000000000000000000000000000000000000000
diff --git a/browser/country-extra/README.md b/browser/country-extra/README.md
deleted file mode 100644
index 71e7237722915b2697b56ccb14171524eb4b40fb..0000000000000000000000000000000000000000
diff --git a/browser/geocode/README.md b/browser/geocode/README.md
deleted file mode 100644
index 9d9a2205061f332b363b82d7561a0e3829d5bf2c..0000000000000000000000000000000000000000
diff --git a/browser/geocode-extra/README.md b/browser/geocode-extra/README.md
deleted file mode 100644
index 38e17eebdd8532d07b460fbe7f385f36625ece9d..0000000000000000000000000000000000000000
diff --git a/src/db.mjs b/src/db.mjs
index 378b8a22084f860cc89720d1783a235c034717b2..cbffe1eaa84a94df536059d4b4af3f8f5ceb0ca7 100644
--- a/src/db.mjs
+++ b/src/db.mjs
@@ -33,7 +33,12 @@ export const update = async () => {
if(refreshTmpDir || !fsSync.existsSync(setting.tmpDataDir)){
// refresh tmp folder
await rimraf(setting.tmpDataDir)
- await fs.mkdir(setting.tmpDataDir)
+ await fs.mkdir(setting.tmpDataDir, {recursive: true})
+ }
+
+ // When specifying a custom dataDir, it doesn't always exists
+ if (!fsSync.existsSync(setting.dataDir)){
+ await fs.mkdir(setting.dataDir, {recursive: true})
}
console.log('Downloading database')
diff --git a/src/main.mjs b/src/main.mjs
index d001aca60902bc7fe41271c6fa7a0b6648607b15..5b2c125d8e7590afee82c794bf771accd656b2b7 100644
--- a/src/main.mjs
+++ b/src/main.mjs
@@ -3,7 +3,7 @@ import fs from 'fs/promises'
import fsSync from 'fs'
import path from 'path'
import { exec } from 'child_process'
-
+import { fileURLToPath } from "url"
import { countries, continents } from 'countries-list'
import { setting, setSetting, getSettingCmd } from './setting.mjs'
@@ -14,6 +14,9 @@ const v6db = setting.v6
const locFieldHash = setting.locFieldHash
const mainFieldHash = setting.mainFieldHash
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = path.dirname(__filename)
+
//---------------------------------------
// Database lookup
//---------------------------------------
@@ -235,7 +238,7 @@ export const updateDb = (_setting) => {
// However, db.js import many external modules, it makes slow down the startup time and uses more memory.
// Therefore, we use exec() to run the script in the other process.
return new Promise((resolve, reject) => {
- var cmd = 'node ' + path.resolve(__dirname, '..', 'script', 'updatedb.js')
+ var cmd = 'node ' + path.resolve(__dirname, '..', 'script', 'updatedb.mjs')
var arg
if(_setting){
var oldSetting = Object.assign({}, setting)

1163
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,9 @@
},
{
"path": "./apps/api-worker/tsconfig.app.json"
},
{
"path": "./apps/discord-bot/tsconfig.app.json"
}
],
"files": []