mirror of
https://github.com/PreMiD/PreMiD.git
synced 2026-04-06 04:41:58 +02:00
Compare commits
83 Commits
api-master
...
api-worker
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb4ecf7c95 | ||
|
|
26a0fee323 | ||
|
|
b48dc12c8f | ||
|
|
dcf3218d0b | ||
|
|
bdcc05a300 | ||
|
|
51b4508a1c | ||
|
|
378671b267 | ||
|
|
53a02c98e0 | ||
|
|
b60c991fea | ||
|
|
3b178d70b9 | ||
|
|
ef377d3f68 | ||
|
|
d5acdefc45 | ||
|
|
39ffee4126 | ||
|
|
ed30c593ad | ||
|
|
9ffa6addb3 | ||
|
|
95fd02b513 | ||
|
|
3198ac3704 | ||
|
|
0d4b53f382 | ||
|
|
3600ef87a5 | ||
|
|
55ffabb352 | ||
|
|
5a90f95e58 | ||
|
|
64547dc0ef | ||
|
|
9cf3f93889 | ||
|
|
0e30a0d250 | ||
|
|
4dc941bb91 | ||
|
|
3a78c6529e | ||
|
|
d4673720a0 | ||
|
|
dc859448bd | ||
|
|
9cbb88beda | ||
|
|
09bcfe703f | ||
|
|
d24eda8957 | ||
|
|
bfffcb94ee | ||
|
|
4db6a78816 | ||
|
|
666838874f | ||
|
|
697f3660c2 | ||
|
|
a668add973 | ||
|
|
42b70b1259 | ||
|
|
253b680d3e | ||
|
|
e9a40dc553 | ||
|
|
b25880d4cd | ||
|
|
fb06227aeb | ||
|
|
ff3d00497b | ||
|
|
a06780f85a | ||
|
|
5b1969c7ab | ||
|
|
bedd34594c | ||
|
|
47feaa5c70 | ||
|
|
9fb32f53ae | ||
|
|
bfb84bb080 | ||
|
|
f545b174bd | ||
|
|
4a492cf275 | ||
|
|
3f65f678b1 | ||
|
|
a71b66540b | ||
|
|
e675f74983 | ||
|
|
e9e6639492 | ||
|
|
3258179040 | ||
|
|
086d476af2 | ||
|
|
146bf9e270 | ||
|
|
a02f25ba29 | ||
|
|
416b65f0d4 | ||
|
|
f8e9fc832d | ||
|
|
86b0f07216 | ||
|
|
9eb5c03877 | ||
|
|
e63e1270aa | ||
|
|
f730e71bbf | ||
|
|
8b68bf85c8 | ||
|
|
e4c794a9ad | ||
|
|
6e8258d76f | ||
|
|
56b796c621 | ||
|
|
0de59c48b4 | ||
|
|
60056e069d | ||
|
|
b6bad90919 | ||
|
|
ee21bb9dec | ||
|
|
6efac4fef1 | ||
|
|
93424793bd | ||
|
|
affcb6a0cf | ||
|
|
bb56949dfb | ||
|
|
c06fe04b65 | ||
|
|
ef976341ba | ||
|
|
38893891af | ||
|
|
63eeeefda7 | ||
|
|
056db21cb0 | ||
|
|
d8dc08c6c3 | ||
|
|
634391b6e3 |
1
.github/workflows/cd.yaml
vendored
1
.github/workflows/cd.yaml
vendored
@@ -20,6 +20,7 @@ jobs:
|
||||
- website
|
||||
- api-worker
|
||||
- api-master
|
||||
- discord-bot
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
1
.github/workflows/ci.yaml
vendored
1
.github/workflows/ci.yaml
vendored
@@ -48,6 +48,7 @@ jobs:
|
||||
- api-worker
|
||||
- api-master
|
||||
- website
|
||||
- discord-bot
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -3,6 +3,7 @@ out
|
||||
dist
|
||||
tmp
|
||||
lib
|
||||
data
|
||||
|
||||
.vscode
|
||||
.env
|
||||
@@ -19,7 +20,9 @@ src/update.ini
|
||||
*.app
|
||||
*.xml.backup
|
||||
*.js
|
||||
!eslint.config.js
|
||||
!*.config.js
|
||||
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -1,2 +1,3 @@
|
||||
*.js
|
||||
*.ts
|
||||
*.ts
|
||||
*.json
|
||||
10
apps/api-master/drizzle.config.ts
Normal file
10
apps/api-master/drizzle.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
dbCredentials: {
|
||||
url: "postgresql://metrics:metrics@localhost:5432/metrics",
|
||||
},
|
||||
dialect: "postgresql",
|
||||
schema: "./src/db.ts",
|
||||
out: "./drizzle",
|
||||
});
|
||||
9
apps/api-master/drizzle/0000_flippant_marrow.sql
Normal file
9
apps/api-master/drizzle/0000_flippant_marrow.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS "online_users_ip_data" (
|
||||
"uuid" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"ip" varchar(45) NOT NULL,
|
||||
"country" varchar(2) NOT NULL,
|
||||
"latitude" numeric(10, 8) NOT NULL,
|
||||
"longitude" numeric(11, 8) NOT NULL,
|
||||
"name" varchar(255),
|
||||
"timestamp" timestamp DEFAULT now()
|
||||
);
|
||||
2
apps/api-master/drizzle/0001_white_lifeguard.sql
Normal file
2
apps/api-master/drizzle/0001_white_lifeguard.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX IF NOT EXISTS "idx_online_users_uuid" ON "online_users_ip_data" USING btree ("uuid");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "idx_online_users_timestamp" ON "online_users_ip_data" USING btree ("timestamp");
|
||||
1
apps/api-master/drizzle/0002_new_darkhawk.sql
Normal file
1
apps/api-master/drizzle/0002_new_darkhawk.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "online_users_ip_data" ALTER COLUMN "timestamp" SET DATA TYPE timestamp with time zone;
|
||||
2
apps/api-master/drizzle/0003_narrow_mastermind.sql
Normal file
2
apps/api-master/drizzle/0003_narrow_mastermind.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "online_users_ip_data" ADD COLUMN "presences" jsonb DEFAULT '[]' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "online_users_ip_data" DROP COLUMN IF EXISTS "name";
|
||||
1
apps/api-master/drizzle/0004_tiresome_puff_adder.sql
Normal file
1
apps/api-master/drizzle/0004_tiresome_puff_adder.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "online_users_ip_data" ADD COLUMN "sessions" integer DEFAULT 0 NOT NULL;
|
||||
70
apps/api-master/drizzle/meta/0000_snapshot.json
Normal file
70
apps/api-master/drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"id": "e29a6708-01f1-455a-b345-63dac1e124dc",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.online_users_ip_data": {
|
||||
"name": "online_users_ip_data",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"uuid": {
|
||||
"name": "uuid",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"ip": {
|
||||
"name": "ip",
|
||||
"type": "varchar(45)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"country": {
|
||||
"name": "country",
|
||||
"type": "varchar(2)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"latitude": {
|
||||
"name": "latitude",
|
||||
"type": "numeric(10, 8)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"longitude": {
|
||||
"name": "longitude",
|
||||
"type": "numeric(11, 8)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"timestamp": {
|
||||
"name": "timestamp",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
101
apps/api-master/drizzle/meta/0001_snapshot.json
Normal file
101
apps/api-master/drizzle/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"id": "4aa32a8e-f573-43b9-976a-2d078a0df0ea",
|
||||
"prevId": "e29a6708-01f1-455a-b345-63dac1e124dc",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.online_users_ip_data": {
|
||||
"name": "online_users_ip_data",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"uuid": {
|
||||
"name": "uuid",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"ip": {
|
||||
"name": "ip",
|
||||
"type": "varchar(45)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"country": {
|
||||
"name": "country",
|
||||
"type": "varchar(2)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"latitude": {
|
||||
"name": "latitude",
|
||||
"type": "numeric(10, 8)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"longitude": {
|
||||
"name": "longitude",
|
||||
"type": "numeric(11, 8)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"timestamp": {
|
||||
"name": "timestamp",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_online_users_uuid": {
|
||||
"name": "idx_online_users_uuid",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "uuid",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_online_users_timestamp": {
|
||||
"name": "idx_online_users_timestamp",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "timestamp",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
101
apps/api-master/drizzle/meta/0002_snapshot.json
Normal file
101
apps/api-master/drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"id": "c1b8dbed-b232-4d66-9e74-b9af333095bc",
|
||||
"prevId": "4aa32a8e-f573-43b9-976a-2d078a0df0ea",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.online_users_ip_data": {
|
||||
"name": "online_users_ip_data",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"uuid": {
|
||||
"name": "uuid",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"ip": {
|
||||
"name": "ip",
|
||||
"type": "varchar(45)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"country": {
|
||||
"name": "country",
|
||||
"type": "varchar(2)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"latitude": {
|
||||
"name": "latitude",
|
||||
"type": "numeric(10, 8)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"longitude": {
|
||||
"name": "longitude",
|
||||
"type": "numeric(11, 8)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"timestamp": {
|
||||
"name": "timestamp",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_online_users_uuid": {
|
||||
"name": "idx_online_users_uuid",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "uuid",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_online_users_timestamp": {
|
||||
"name": "idx_online_users_timestamp",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "timestamp",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
102
apps/api-master/drizzle/meta/0003_snapshot.json
Normal file
102
apps/api-master/drizzle/meta/0003_snapshot.json
Normal file
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"id": "e409a4d0-f698-484a-b412-38966a7b3a19",
|
||||
"prevId": "c1b8dbed-b232-4d66-9e74-b9af333095bc",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.online_users_ip_data": {
|
||||
"name": "online_users_ip_data",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"uuid": {
|
||||
"name": "uuid",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"ip": {
|
||||
"name": "ip",
|
||||
"type": "varchar(45)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"country": {
|
||||
"name": "country",
|
||||
"type": "varchar(2)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"latitude": {
|
||||
"name": "latitude",
|
||||
"type": "numeric(10, 8)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"longitude": {
|
||||
"name": "longitude",
|
||||
"type": "numeric(11, 8)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"presences": {
|
||||
"name": "presences",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'[]'"
|
||||
},
|
||||
"timestamp": {
|
||||
"name": "timestamp",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_online_users_uuid": {
|
||||
"name": "idx_online_users_uuid",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "uuid",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_online_users_timestamp": {
|
||||
"name": "idx_online_users_timestamp",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "timestamp",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
109
apps/api-master/drizzle/meta/0004_snapshot.json
Normal file
109
apps/api-master/drizzle/meta/0004_snapshot.json
Normal file
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"id": "179435b5-dc15-4a42-9539-c3f336699d63",
|
||||
"prevId": "e409a4d0-f698-484a-b412-38966a7b3a19",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.online_users_ip_data": {
|
||||
"name": "online_users_ip_data",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"uuid": {
|
||||
"name": "uuid",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"ip": {
|
||||
"name": "ip",
|
||||
"type": "varchar(45)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"country": {
|
||||
"name": "country",
|
||||
"type": "varchar(2)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"latitude": {
|
||||
"name": "latitude",
|
||||
"type": "numeric(10, 8)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"longitude": {
|
||||
"name": "longitude",
|
||||
"type": "numeric(11, 8)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"presences": {
|
||||
"name": "presences",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'[]'"
|
||||
},
|
||||
"timestamp": {
|
||||
"name": "timestamp",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_online_users_uuid": {
|
||||
"name": "idx_online_users_uuid",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "uuid",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_online_users_timestamp": {
|
||||
"name": "idx_online_users_timestamp",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "timestamp",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
41
apps/api-master/drizzle/meta/_journal.json
Normal file
41
apps/api-master/drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1726516195146,
|
||||
"tag": "0000_flippant_marrow",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1726516348344,
|
||||
"tag": "0001_white_lifeguard",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1726516660134,
|
||||
"tag": "0002_new_darkhawk",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1726517073510,
|
||||
"tag": "0003_narrow_mastermind",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1726517405363,
|
||||
"tag": "0004_tiresome_puff_adder",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
16
apps/api-master/environment.d.ts
vendored
Normal file
16
apps/api-master/environment.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
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; autoUpdate?: string }): Promise<void>;
|
||||
export function reload(options: { fields?: string[]; dataDir?: string; tmpDataDir?: string; smallMemory?: boolean; autoUpdate?: string }): Promise<void>;
|
||||
}
|
||||
|
||||
declare namespace NodeJS {
|
||||
export interface ProcessEnv {
|
||||
METRICS_DATABASE_URL?: string;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@premid/api-master",
|
||||
"type": "module",
|
||||
"version": "0.0.14",
|
||||
"version": "0.0.37",
|
||||
"private": true,
|
||||
"description": "PreMiD's api master",
|
||||
"license": "MPL-2.0",
|
||||
@@ -11,21 +11,27 @@
|
||||
],
|
||||
"scripts": {
|
||||
"start": "node --enable-source-maps .",
|
||||
"dev": "node --watch --env-file .env --enable-source-maps ."
|
||||
"dev": "node --watch --env-file .env --enable-source-maps .",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:update": "pnpm db:generate && pnpm db:migrate",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordjs/rest": "^2.3.0",
|
||||
"@envelop/sentry": "^9.0.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.52.1",
|
||||
"@opentelemetry/node": "^0.24.0",
|
||||
"@sentry/node": "^8.17.0",
|
||||
"cron": "^3.1.7",
|
||||
"debug": "^4.3.6",
|
||||
"drizzle-orm": "^0.33.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"p-limit": "^6.1.0"
|
||||
"ip-location-api": "^2.0.1",
|
||||
"ky": "^1.7.2",
|
||||
"p-limit": "^6.1.0",
|
||||
"postgres": "^3.4.4",
|
||||
"prom-client": "^15.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/debug": "^4.1.12"
|
||||
"@types/debug": "^4.1.12",
|
||||
"drizzle-kit": "^0.24.2"
|
||||
}
|
||||
}
|
||||
|
||||
27
apps/api-master/src/db.ts
Normal file
27
apps/api-master/src/db.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import process from "node:process";
|
||||
import { decimal, index, integer, jsonb, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
|
||||
// Define the schema
|
||||
export const onlineUsersIpData = pgTable("online_users_ip_data", {
|
||||
uuid: uuid("uuid").primaryKey().defaultRandom(),
|
||||
ip: varchar("ip", { length: 45 }).notNull(),
|
||||
country: varchar("country", { length: 2 }).notNull(),
|
||||
latitude: decimal("latitude", { precision: 10, scale: 8 }).notNull(),
|
||||
longitude: decimal("longitude", { precision: 11, scale: 8 }).notNull(),
|
||||
sessions: integer("sessions").notNull().default(0),
|
||||
presences: jsonb("presences").notNull().default("[]").$type<string[]>(),
|
||||
timestamp: timestamp("timestamp", { withTimezone: true }).defaultNow(),
|
||||
}, table => ({
|
||||
idxOnlineUsersUuid: index("idx_online_users_uuid").on(table.uuid),
|
||||
idxOnlineUsersTimestamp: index("idx_online_users_timestamp").on(table.timestamp),
|
||||
}));
|
||||
|
||||
if (!process.env.METRICS_DATABASE_URL) {
|
||||
throw new Error("METRICS_DATABASE_URL is not set");
|
||||
}
|
||||
|
||||
export const sql = postgres(process.env.METRICS_DATABASE_URL);
|
||||
|
||||
export const db = drizzle(sql);
|
||||
10
apps/api-master/src/functions/cleanupOldUserData.ts
Normal file
10
apps/api-master/src/functions/cleanupOldUserData.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { lt, sql } from "drizzle-orm";
|
||||
import { db, onlineUsersIpData } from "../db.js";
|
||||
import { mainLog } from "../index.js";
|
||||
|
||||
export async function cleanupOldUserData(retentionDays: number) {
|
||||
mainLog("Cleaning up old user ip data");
|
||||
const interval = `'${retentionDays} days'`;
|
||||
await db.delete(onlineUsersIpData)
|
||||
.where(lt(onlineUsersIpData.timestamp, sql`now() - interval ${sql.raw(interval)}`));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { REST } from "@discordjs/rest";
|
||||
import pLimit from "p-limit";
|
||||
import ky, { HTTPError, TimeoutError } from "ky";
|
||||
import { mainLog, redis } from "../index.js";
|
||||
|
||||
let inProgress = false;
|
||||
@@ -11,6 +11,7 @@ export async function clearOldSessions() {
|
||||
|
||||
inProgress = true;
|
||||
const now = Date.now();
|
||||
const pattern = "pmd-api.sessions.*";
|
||||
let cursor = "0";
|
||||
let totalSessions = 0;
|
||||
let cleared = 0;
|
||||
@@ -22,21 +23,15 @@ export async function clearOldSessions() {
|
||||
const limit = pLimit(100); // Create a limit of 100 concurrent operations
|
||||
|
||||
do {
|
||||
const [nextCursor, result] = await redis.hscan("pmd-api.sessions", cursor, "COUNT", batchSize);
|
||||
cursor = nextCursor;
|
||||
totalSessions += result.length / 2;
|
||||
const [newCursor, keys] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 1000); //* Use SCAN with COUNT for memory efficiency
|
||||
|
||||
const deletePromises = [];
|
||||
cursor = newCursor;
|
||||
totalSessions += keys.length;
|
||||
|
||||
for (let i = 0; i < result.length; i += 2) {
|
||||
const key = result[i];
|
||||
const value = result[i + 1];
|
||||
const deletePromises: Promise<string>[] = [];
|
||||
|
||||
if (!key || !value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const session = JSON.parse(value) as {
|
||||
for (const key of keys) {
|
||||
const session = await redis.hgetall(key) as unknown as {
|
||||
token: string;
|
||||
session: string;
|
||||
lastUpdated: number;
|
||||
@@ -57,13 +52,13 @@ export async function clearOldSessions() {
|
||||
});
|
||||
|
||||
if (keysToDelete.length >= batchSize) {
|
||||
await redis.hdel("pmd-api.sessions", ...keysToDelete);
|
||||
await redis.del(...keysToDelete);
|
||||
keysToDelete = [];
|
||||
}
|
||||
} while (cursor !== "0");
|
||||
|
||||
if (keysToDelete.length > 0) {
|
||||
await redis.hdel("pmd-api.sessions", ...keysToDelete);
|
||||
await redis.del(...keysToDelete);
|
||||
}
|
||||
|
||||
if (totalSessions === 0) {
|
||||
@@ -76,27 +71,30 @@ export async function clearOldSessions() {
|
||||
inProgress = false;
|
||||
}
|
||||
|
||||
async function deleteSession(session: { token: string; session: string }, key: string): Promise<string | null> {
|
||||
async function deleteSession(session: { token: string; session: string }, key: string): Promise<string> {
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
const discord = new REST({ version: "10", authPrefix: "Bearer" });
|
||||
discord.setToken(session.token);
|
||||
setTimeout(() => abortController.abort(), 5000);
|
||||
await discord.post("/users/@me/headless-sessions/delete", {
|
||||
signal: abortController.signal,
|
||||
body: {
|
||||
await ky.post("https://discord.com/api/v10/users/@me/headless-sessions/delete", {
|
||||
json: {
|
||||
token: session.session,
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.token}`,
|
||||
},
|
||||
retry: 3,
|
||||
timeout: 5000,
|
||||
});
|
||||
return key;
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
mainLog("Timeout while deleting session");
|
||||
if (error instanceof TimeoutError) {
|
||||
mainLog(`Session deletion aborted due to timeout for key ${key}`);
|
||||
}
|
||||
else if (error instanceof HTTPError) {
|
||||
mainLog(`Failed to delete session for key ${key}: [${error.name}] ${error.message} ${JSON.stringify(await error.response.json())}`);
|
||||
}
|
||||
else {
|
||||
mainLog(`Failed to delete session: %O`, (typeof error === "object" && error && "message" in error ? error.message : error));
|
||||
mainLog(`Failed to delete session for key ${key}: Unknown error`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
40
apps/api-master/src/functions/insertIpData.ts
Normal file
40
apps/api-master/src/functions/insertIpData.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { InferInsertModel } from "drizzle-orm";
|
||||
import { db, onlineUsersIpData } from "../db.js";
|
||||
import { lookupIp } from "./lookupIp.js";
|
||||
|
||||
const batchSize = 1000;
|
||||
|
||||
export async function insertIpData(
|
||||
data: Map<string, {
|
||||
presences: string[];
|
||||
sessions: number;
|
||||
}>,
|
||||
) {
|
||||
const timestamp = new Date();
|
||||
const list = [...data.keys()];
|
||||
//* Split into batches of batchSize
|
||||
for (let i = 0; i < list.length; i += batchSize) {
|
||||
const batch = list.slice(i, i + batchSize);
|
||||
const mapped = await Promise.all(batch.map(async (ip) => {
|
||||
const parsed = await lookupIp(ip);
|
||||
if (parsed) {
|
||||
const { presences, sessions } = data.get(ip)!;
|
||||
return {
|
||||
ip,
|
||||
country: parsed.country,
|
||||
latitude: parsed.latitude.toString(),
|
||||
longitude: parsed.longitude.toString(),
|
||||
presences,
|
||||
sessions,
|
||||
timestamp,
|
||||
} satisfies InferInsertModel<typeof onlineUsersIpData>;
|
||||
}
|
||||
}));
|
||||
|
||||
const toInsert = mapped.filter(Boolean) as InferInsertModel<typeof onlineUsersIpData>[];
|
||||
|
||||
if (toInsert.length > 0) {
|
||||
await db.insert(onlineUsersIpData).values(toInsert);
|
||||
}
|
||||
}
|
||||
}
|
||||
46
apps/api-master/src/functions/lookupIp.ts
Normal file
46
apps/api-master/src/functions/lookupIp.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { join } from "node:path";
|
||||
import process from "node:process";
|
||||
import { lookup, reload } 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;
|
||||
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");
|
||||
reload({ fields, dataDir, tmpDataDir, smallMemory, autoUpdate: "0 9 * * *" }).then(() => {
|
||||
log?.("IP location API reloaded");
|
||||
initialized = true;
|
||||
reloading = undefined;
|
||||
resolve();
|
||||
}).catch(reject);
|
||||
});
|
||||
return reloading;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { redis } from "../index.js";
|
||||
import { counter } from "../tracing.js";
|
||||
|
||||
let activeActivities = 0;
|
||||
counter.add(0);
|
||||
export async function setCounter() {
|
||||
const length = await redis.hlen("pmd-api.sessions");
|
||||
if (length === activeActivities)
|
||||
return;
|
||||
const diff = length - activeActivities;
|
||||
activeActivities = length;
|
||||
counter.add(diff);
|
||||
}
|
||||
25
apps/api-master/src/functions/setupServer.ts
Normal file
25
apps/api-master/src/functions/setupServer.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import http from "node:http";
|
||||
import { mainLog } from "../index.js";
|
||||
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());
|
||||
});
|
||||
|
||||
server.listen(9464, () => {
|
||||
mainLog("Server running");
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
77
apps/api-master/src/functions/updateActivePresenceGauge.ts
Normal file
77
apps/api-master/src/functions/updateActivePresenceGauge.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import process from "node:process";
|
||||
import pLimit from "p-limit";
|
||||
import type { Gauge } from "prom-client";
|
||||
import { mainLog, redis } from "../index.js";
|
||||
import { insertIpData } from "./insertIpData.js";
|
||||
|
||||
export const updateActivePresenceGaugeLimit = pLimit(1);
|
||||
let log: debug.Debugger | undefined;
|
||||
|
||||
const scanCount = Number.parseInt(process.env.SCAN_COUNT || "1000", 10);
|
||||
|
||||
export async function updateActivePresenceGauge(gauge: Gauge) {
|
||||
await updateActivePresenceGaugeLimit(async () => {
|
||||
log ??= mainLog.extend("Heartbeat-Updates");
|
||||
log?.("Starting active presence gauge update");
|
||||
|
||||
const pattern = "pmd-api.heartbeatUpdates.*";
|
||||
let cursor: string = "0";
|
||||
const serviceCounts = new Map<string, number>();
|
||||
const ips = new Map<string, {
|
||||
presences: Set<string>;
|
||||
sessions: 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, "service", "version", "ip_address"));
|
||||
const hashes = await pipeline.exec();
|
||||
|
||||
if (!hashes) {
|
||||
log?.("No hashes found");
|
||||
return;
|
||||
}
|
||||
|
||||
hashes.forEach(([err, hash]) => {
|
||||
if (err || !Array.isArray(hash))
|
||||
return;
|
||||
|
||||
const [service, version, ip] = hash;
|
||||
const serviceVersion = service && version ? `${service}:${version}` : "none";
|
||||
serviceCounts.set(serviceVersion, (serviceCounts.get(serviceVersion) || 0) + 1);
|
||||
|
||||
if (ip) {
|
||||
const ipData = ips.get(ip) || { presences: new Set(), sessions: 0 };
|
||||
if (serviceVersion !== "none")
|
||||
ipData.presences.add(serviceVersion);
|
||||
ipData.sessions++;
|
||||
ips.set(ip, ipData);
|
||||
}
|
||||
});
|
||||
} while (cursor !== "0");
|
||||
|
||||
log?.("Updating active presence gauge");
|
||||
|
||||
//* Batch update the gauge
|
||||
gauge.reset();
|
||||
for (const [serviceVersion, count] of serviceCounts) {
|
||||
const [presence_name, version] = serviceVersion.split(":");
|
||||
gauge.set({ presence_name, version }, count);
|
||||
}
|
||||
|
||||
//* Convert IP data for insertion
|
||||
const ipDataForInsertion = new Map(
|
||||
Array.from(ips, ([ip, data]) => [ip, {
|
||||
presences: Array.from(data.presences),
|
||||
sessions: data.sessions,
|
||||
}]),
|
||||
);
|
||||
|
||||
await insertIpData(ipDataForInsertion);
|
||||
log?.("Active presence gauge update completed");
|
||||
});
|
||||
}
|
||||
@@ -1,33 +1,44 @@
|
||||
import process from "node:process";
|
||||
import { CronJob } from "cron";
|
||||
|
||||
import debug from "debug";
|
||||
import { clearOldSessions } from "./functions/clearOldSessions.js";
|
||||
import createRedis from "./functions/createRedis.js";
|
||||
import { setCounter } from "./functions/setCounter.js";
|
||||
import "./tracing.js";
|
||||
import { reloadIpLocationApi } from "./functions/lookupIp.js";
|
||||
import { cleanupOldUserData } from "./functions/cleanupOldUserData.js";
|
||||
import { setupServer } from "./functions/setupServer.js";
|
||||
|
||||
export const redis = createRedis();
|
||||
|
||||
export const server = setupServer();
|
||||
|
||||
export const mainLog = debug("api-master");
|
||||
|
||||
debug("Starting cron job to clear old sessions");
|
||||
debug("Starting cron jobs");
|
||||
|
||||
void reloadIpLocationApi();
|
||||
|
||||
void new CronJob(
|
||||
// Every 5 seconds
|
||||
"*/5 * * * * *",
|
||||
() => {
|
||||
clearOldSessions();
|
||||
if (process.env.DISABLE_CLEAR_OLD_SESSIONS !== "true") {
|
||||
clearOldSessions();
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
void new CronJob(
|
||||
// Every second
|
||||
"* * * * * *",
|
||||
// Every day at 1am
|
||||
"0 1 * * *",
|
||||
() => {
|
||||
setCounter();
|
||||
cleanupOldUserData(14); // Keep 14 days of data
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -1,18 +1,41 @@
|
||||
import { ValueType } from "@opentelemetry/api";
|
||||
import { PrometheusExporter } from "@opentelemetry/exporter-prometheus";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import process from "node:process";
|
||||
import { Counter, Gauge, Registry, collectDefaultMetrics } from "prom-client";
|
||||
import { updateActivePresenceGauge, updateActivePresenceGaugeLimit } from "./functions/updateActivePresenceGauge.js";
|
||||
import { redis } from "./index.js";
|
||||
|
||||
const prometheusExporter = new PrometheusExporter();
|
||||
const scanCount = Number.parseInt(process.env.SCAN_COUNT || "1000", 10);
|
||||
|
||||
const provider = new MeterProvider({
|
||||
readers: [prometheusExporter],
|
||||
export const register = new Registry();
|
||||
collectDefaultMetrics({ register });
|
||||
|
||||
export const activeSessionsCounter = new Counter({
|
||||
name: "active_sessions",
|
||||
help: "Number of active sessions",
|
||||
async collect() {
|
||||
this.reset();
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
const meter = provider.getMeter("nice");
|
||||
|
||||
export const counter = meter.createUpDownCounter("active_activites", {
|
||||
description: "Number of active activities",
|
||||
valueType: ValueType.INT,
|
||||
export const activePresencesCounter = new Gauge({
|
||||
name: "active_presences",
|
||||
help: "Number of active presences",
|
||||
labelNames: ["presence_name", "version"],
|
||||
async collect() {
|
||||
if (process.env.DISABLE_ACTIVE_PRESENCE_GAUGE !== "true") {
|
||||
this.reset();
|
||||
updateActivePresenceGaugeLimit.clearQueue();
|
||||
await updateActivePresenceGauge(this);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
prometheusExporter.startServer();
|
||||
register.registerMetric(activeSessionsCounter);
|
||||
register.registerMetric(activePresencesCounter);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"rootDir": "src",
|
||||
"types": ["./environment.d.ts"],
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@premid/api-worker",
|
||||
"type": "module",
|
||||
"version": "0.0.8",
|
||||
"version": "0.0.16",
|
||||
"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",
|
||||
|
||||
10
apps/api-worker/src/constants.ts
Normal file
10
apps/api-worker/src/constants.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import process from "node:process";
|
||||
import { defu } from "defu";
|
||||
|
||||
const disabledFlags = process.env.DISABLED_FEATURE_FLAGS?.split(",") ?? [];
|
||||
const flags = Object.fromEntries(disabledFlags.map(flag => [flag, false]));
|
||||
|
||||
export const featureFlags = defu(flags, {
|
||||
WebSocketManager: true,
|
||||
SessionKeepAlive: true,
|
||||
});
|
||||
@@ -1,13 +1,10 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import process from "node:process";
|
||||
import { useSentry } from "@envelop/sentry";
|
||||
import { maxAliasesPlugin } from "@escape.tech/graphql-armor-max-aliases";
|
||||
import { maxDepthPlugin } from "@escape.tech/graphql-armor-max-depth";
|
||||
import { maxDirectivesPlugin } from "@escape.tech/graphql-armor-max-directives";
|
||||
import { maxTokensPlugin } from "@escape.tech/graphql-armor-max-tokens";
|
||||
import fastifyWebsocket from "@fastify/websocket";
|
||||
import { defu } from "defu";
|
||||
import fastify from "fastify";
|
||||
|
||||
import { createSchema, createYoga } from "graphql-yoga";
|
||||
@@ -15,6 +12,7 @@ import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
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 createRedis from "./createRedis.js";
|
||||
|
||||
export interface FastifyContext {
|
||||
@@ -48,7 +46,7 @@ export default async function createServer() {
|
||||
maxDepthPlugin(),
|
||||
maxDirectivesPlugin(),
|
||||
maxTokensPlugin(),
|
||||
useSentry(),
|
||||
/* useSentry(), */
|
||||
],
|
||||
schema: createSchema<FastifyContext>({
|
||||
resolvers,
|
||||
@@ -87,15 +85,7 @@ export default async function createServer() {
|
||||
});
|
||||
|
||||
app.get("/v5/feature-flags", async (request, reply) => {
|
||||
const disabledFlags = process.env.DISABLED_FEATURE_FLAGS?.split(",") ?? [];
|
||||
const flags = Object.fromEntries(disabledFlags.map(flag => [flag, false]));
|
||||
|
||||
const test = defu(flags, {
|
||||
WebSocketManager: true,
|
||||
SessionKeepAlive: true,
|
||||
});
|
||||
|
||||
void reply.send(test);
|
||||
void reply.send(featureFlags);
|
||||
});
|
||||
|
||||
app.post("/v5/session-keep-alive", sessionKeepAlive);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type } from "arktype";
|
||||
import { GraphQLError } from "graphql";
|
||||
import { redis } from "../../../../functions/createServer.js";
|
||||
import type { MutationResolvers } from "../../../../generated/graphql-v5.js";
|
||||
|
||||
@@ -15,7 +16,7 @@ const mutation: MutationResolvers["addScience"] = async (_parent, input) => {
|
||||
const out = addScienceSchema(input);
|
||||
|
||||
if (out instanceof type.errors)
|
||||
throw new Error(out.summary);
|
||||
throw new GraphQLError(out.summary);
|
||||
|
||||
await redis.hset(
|
||||
"pmd-api.scienceUpdates",
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { type } from "arktype";
|
||||
import { GraphQLError } from "graphql";
|
||||
import type { MutationResolvers } from "../../../../generated/graphql-v5.js";
|
||||
import { redis } from "../../../../functions/createServer.js";
|
||||
|
||||
const heartbeatSchema = type({
|
||||
identifier: "string.uuid & string.lower",
|
||||
presences: {
|
||||
"identifier": "string.uuid & string.lower",
|
||||
"presence?": {
|
||||
service: "string.trim",
|
||||
version: "string.semver",
|
||||
language: "string.trim",
|
||||
since: "number.epoch",
|
||||
},
|
||||
extension: {
|
||||
"extension": {
|
||||
"version": "string.semver",
|
||||
"language": "string.trim",
|
||||
"connected?": {
|
||||
@@ -19,19 +21,29 @@ const heartbeatSchema = type({
|
||||
},
|
||||
});
|
||||
|
||||
const mutation: MutationResolvers["heartbeat"] = async (_parent, input) => {
|
||||
const mutation: MutationResolvers["heartbeat"] = async (_parent, input, context) => {
|
||||
const out = heartbeatSchema(input);
|
||||
|
||||
if (out instanceof type.errors)
|
||||
throw new Error(out.summary);
|
||||
throw new GraphQLError(out.summary);
|
||||
|
||||
// ! Disabled for now
|
||||
/* await redis.setex(
|
||||
`pmd-api.heartbeatUpdates.${data.identifier}`,
|
||||
// 5 minutes
|
||||
300,
|
||||
JSON.stringify(data)
|
||||
); */
|
||||
//* Get the user's IP address from Cloudflare headers or fallback to the request IP
|
||||
const userIp = context.request.headers.get("cf-connecting-ip") || context.request.ip;
|
||||
|
||||
// * 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);
|
||||
|
||||
return {
|
||||
__typename: "HeartbeatResult",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ import process from "node:process";
|
||||
import * as Sentry from "@sentry/node";
|
||||
import { connect } from "mongoose";
|
||||
import "./tracing.js";
|
||||
|
||||
// eslint-disable-next-line perfectionist/sort-imports
|
||||
import createServer from "./functions/createServer.js";
|
||||
|
||||
// TODO SETUP SENTRY
|
||||
|
||||
@@ -4,17 +4,25 @@ import { type } from "arktype";
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { redis } from "../functions/createServer.js";
|
||||
import { featureFlags } from "../constants.js";
|
||||
|
||||
const schema = type({
|
||||
token: "string.trim",
|
||||
session: "string.trim",
|
||||
version: "string.semver & string.trim",
|
||||
scienceId: "string.trim",
|
||||
});
|
||||
|
||||
export async function sessionKeepAlive(request: FastifyRequest, reply: FastifyReply) {
|
||||
//* Get the 2 headers
|
||||
if (!featureFlags.SessionKeepAlive)
|
||||
return reply.status(202).send();
|
||||
|
||||
//* Get the headers
|
||||
const out = schema({
|
||||
token: request.headers["x-token"],
|
||||
session: request.headers["x-session"],
|
||||
version: request.headers["x-version"] ?? "2.6.8",
|
||||
scienceId: request.headers["x-science-id"] ?? request.headers["x-token"],
|
||||
});
|
||||
|
||||
if (out instanceof type.errors)
|
||||
@@ -23,17 +31,15 @@ export async function sessionKeepAlive(request: FastifyRequest, reply: FastifyRe
|
||||
if (!await isTokenValid(out.token))
|
||||
return reply.status(400).send({ code: "INVALID_TOKEN", message: "The token is invalid" });
|
||||
|
||||
await redis.hset(
|
||||
"pmd-api.sessions",
|
||||
out.token,
|
||||
JSON.stringify({
|
||||
session: out.session,
|
||||
token: out.token,
|
||||
lastUpdated: Date.now(),
|
||||
}),
|
||||
);
|
||||
const redisKey = `pmd-api.sessions.${out.scienceId}`;
|
||||
await redis.hset(redisKey, {
|
||||
session: out.session,
|
||||
token: out.token,
|
||||
lastUpdated: Date.now(),
|
||||
});
|
||||
await redis.expire(redisKey, 300); // 5 minutes
|
||||
|
||||
const interval = Number.parseInt(process.env.SESSION_KEEP_ALIVE_INTERVAL ?? "5000");
|
||||
const interval = Number.parseInt(process.env.SESSION_KEEP_ALIVE_INTERVAL ?? "5000"); // 5 seconds
|
||||
|
||||
return reply.status(200).send({
|
||||
code: "OK",
|
||||
|
||||
25
apps/discord-bot/package.json
Normal file
25
apps/discord-bot/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@premid/discord-bot",
|
||||
"type": "module",
|
||||
"version": "1.0.5",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
61
apps/discord-bot/src/commands/beta.ts
Normal file
61
apps/discord-bot/src/commands/beta.ts
Normal 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;
|
||||
83
apps/discord-bot/src/commands/help.ts
Normal file
83
apps/discord-bot/src/commands/help.ts
Normal 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;
|
||||
289
apps/discord-bot/src/commands/info.ts
Normal file
289
apps/discord-bot/src/commands/info.ts
Normal 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;
|
||||
98
apps/discord-bot/src/commands/presence.ts
Normal file
98
apps/discord-bot/src/commands/presence.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { AutocompleteInteraction, ChatInputCommandInteraction } from "discord.js";
|
||||
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, SlashCommandBuilder } from "discord.js";
|
||||
import { Presence } from "@premid/db";
|
||||
import { createStandardEmbed } from "../util/createStandardEmbed.js";
|
||||
import type { Command } from "../util/loadCommands.js";
|
||||
import { getPresenceList } from "../util/presenceList.js";
|
||||
import { client } from "../constants.js";
|
||||
|
||||
export default {
|
||||
data: new SlashCommandBuilder()
|
||||
.setName("presence")
|
||||
.setDescription("Search for a presence")
|
||||
.addStringOption(option =>
|
||||
option
|
||||
.setName("query")
|
||||
.setDescription("The presence to search for")
|
||||
.setAutocomplete(true)
|
||||
.setRequired(true),
|
||||
),
|
||||
autocomplete: async (interaction: AutocompleteInteraction) => {
|
||||
const focusedValue = interaction.options.getFocused();
|
||||
const presenceList = getPresenceList();
|
||||
const filtered = presenceList.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 presence = await Presence.findOne({ name: query }, {
|
||||
_id: false,
|
||||
metadata: {
|
||||
service: true,
|
||||
author: { id: true },
|
||||
contributors: { id: true },
|
||||
url: true,
|
||||
description: {
|
||||
en: true,
|
||||
},
|
||||
logo: true,
|
||||
color: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!presence)
|
||||
return interaction.reply({ content: "Presence not found", ephemeral: true });
|
||||
|
||||
const embed = createStandardEmbed({
|
||||
title: presence.metadata.service,
|
||||
description: presence.metadata.description.en,
|
||||
color: presence.metadata.color,
|
||||
fields: presence.metadata.contributors?.length
|
||||
? [{
|
||||
name: "Contributors",
|
||||
value: presence.metadata.contributors.map(contributor => `<@${contributor.id}>`).join(", "),
|
||||
}]
|
||||
: undefined,
|
||||
});
|
||||
|
||||
embed.setURL(`https://${Array.isArray(presence.metadata.url) ? presence.metadata.url[0] : presence.metadata.url}`);
|
||||
embed.setThumbnail(presence.metadata.logo);
|
||||
|
||||
const author = await client.users.fetch(presence.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(presence.metadata.service)}`)
|
||||
.setStyle(ButtonStyle.Link),
|
||||
),
|
||||
] });
|
||||
},
|
||||
help: {
|
||||
name: "presence",
|
||||
value: "presence",
|
||||
command: "/presence <query>",
|
||||
commandDescription: "Search for a presence",
|
||||
embed: createStandardEmbed({
|
||||
title: "Command: /presence",
|
||||
description: "Search for a presence",
|
||||
fields: [
|
||||
{ name: "Usage", value: "`/presence <query>`", inline: true },
|
||||
{ name: "Example", value: "`/presence YouTube`", inline: true },
|
||||
],
|
||||
}),
|
||||
},
|
||||
} satisfies Command;
|
||||
69
apps/discord-bot/src/constants.ts
Normal file
69
apps/discord-bot/src/constants.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
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",
|
||||
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",
|
||||
REPRESENTATIVE: "#3BA576",
|
||||
CONTRIBUTOR: "#EB459E",
|
||||
PATRON: "#E5472F",
|
||||
DONATOR: "#FFAA33",
|
||||
BOOSTER: "#F265FF",
|
||||
PROOFREADER: "#00B0E6",
|
||||
TRANSLATOR: "#2286D0",
|
||||
PRESENCE_DEV: "#96A5E9",
|
||||
} as const;
|
||||
31
apps/discord-bot/src/events/guildMemberAdd.ts
Normal file
31
apps/discord-bot/src/events/guildMemberAdd.ts
Normal 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.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");
|
||||
}
|
||||
}
|
||||
});
|
||||
12
apps/discord-bot/src/events/guildMemberRemove.ts
Normal file
12
apps/discord-bot/src/events/guildMemberRemove.ts
Normal 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 }),
|
||||
]);
|
||||
});
|
||||
86
apps/discord-bot/src/events/guildMemberUpdate.ts
Normal file
86
apps/discord-bot/src/events/guildMemberUpdate.ts
Normal 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.username,
|
||||
},
|
||||
},
|
||||
{ upsert: true },
|
||||
),
|
||||
highestRole
|
||||
? Credits.updateOne(
|
||||
{ userId: newMember.id },
|
||||
{
|
||||
$set: {
|
||||
userId: newMember.id,
|
||||
name: 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 }),
|
||||
]);
|
||||
}
|
||||
});
|
||||
42
apps/discord-bot/src/events/interactionCreate.ts
Normal file
42
apps/discord-bot/src/events/interactionCreate.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
180
apps/discord-bot/src/events/ready.ts
Normal file
180
apps/discord-bot/src/events/ready.ts
Normal 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.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);
|
||||
});
|
||||
89
apps/discord-bot/src/index.ts
Normal file
89
apps/discord-bot/src/index.ts
Normal 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 { updatePresenceList } from "./util/presenceList.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 updatePresenceList();
|
||||
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(() => {
|
||||
updatePresenceList();
|
||||
}, 1000 * 60 * 5);
|
||||
25
apps/discord-bot/src/util/createStandardEmbed.ts
Normal file
25
apps/discord-bot/src/util/createStandardEmbed.ts
Normal 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();
|
||||
}
|
||||
22
apps/discord-bot/src/util/getActivity.ts
Normal file
22
apps/discord-bot/src/util/getActivity.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ActivitiesOptions } from "discord.js";
|
||||
import { ActivityType } from "discord.js";
|
||||
import { getPresenceList } from "./presenceList.js";
|
||||
|
||||
export function getActivity({ previous }: {
|
||||
previous?: string;
|
||||
}): ActivitiesOptions {
|
||||
const presenceList = getPresenceList();
|
||||
const statuses = presenceList.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,
|
||||
};
|
||||
}
|
||||
39
apps/discord-bot/src/util/loadCommands.ts
Normal file
39
apps/discord-bot/src/util/loadCommands.ts
Normal 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;
|
||||
}
|
||||
8
apps/discord-bot/src/util/loadEvents.ts
Normal file
8
apps/discord-bot/src/util/loadEvents.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
41
apps/discord-bot/src/util/logger.ts
Normal file
41
apps/discord-bot/src/util/logger.ts
Normal 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(),
|
||||
),
|
||||
}),
|
||||
],
|
||||
});
|
||||
17
apps/discord-bot/src/util/presenceList.ts
Normal file
17
apps/discord-bot/src/util/presenceList.ts
Normal 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 updatePresenceList() {
|
||||
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 getPresenceList() {
|
||||
return presenceList;
|
||||
}
|
||||
8
apps/discord-bot/tsconfig.app.json
Normal file
8
apps/discord-bot/tsconfig.app.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
}
|
||||
}
|
||||
8
apps/discord-bot/tsconfig.json
Normal file
8
apps/discord-bot/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"types": ["@types/node"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@premid/schema-server",
|
||||
"type": "module",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.4",
|
||||
"private": true,
|
||||
"description": "A small service to serve the JSON schemas for PreMiD",
|
||||
"license": "MPL-2.0",
|
||||
|
||||
260
apps/schema-server/schemas/metadata/1.11.json
Normal file
260
apps/schema-server/schemas/metadata/1.11.json
Normal file
@@ -0,0 +1,260 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://schemas.premid.app/metadata/1.11",
|
||||
"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": [
|
||||
"string",
|
||||
"boolean",
|
||||
"array"
|
||||
],
|
||||
"description": "When false, multi-localization is disabled. When true, strings from the `general.json` file are available for use. When a string, it is the name of a file (excluding .json) of a used language from the localization GitHub repo. When an array of strings, it is all of the file names (excluding .json) of used languages from the localization GitHub repo.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "The name of a file from the localization GitHub repository."
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"author",
|
||||
"service",
|
||||
"description",
|
||||
"url",
|
||||
"version",
|
||||
"apiVersion",
|
||||
"logo",
|
||||
"thumbnail",
|
||||
"color",
|
||||
"tags",
|
||||
"category"
|
||||
]
|
||||
}
|
||||
@@ -22,7 +22,6 @@ export const useExtensionStore = defineStore("extension", () => {
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||
function fetchPresences() {
|
||||
window.dispatchEvent(new CustomEvent("PreMiD_GetPresenceList"));
|
||||
}
|
||||
|
||||
1
commitlint.config.js
Normal file
1
commitlint.config.js
Normal file
@@ -0,0 +1 @@
|
||||
export default { extends: ["@commitlint/config-conventional"] };
|
||||
@@ -13,6 +13,6 @@
|
||||
"lib"
|
||||
],
|
||||
"dependencies": {
|
||||
"mongoose": "^8.2.0"
|
||||
"mongoose": "^8.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
11
packages/db/src/AlphaUsers.ts
Normal file
11
packages/db/src/AlphaUsers.ts
Normal 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");
|
||||
11
packages/db/src/BetaUsers.ts
Normal file
11
packages/db/src/BetaUsers.ts
Normal 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");
|
||||
35
packages/db/src/Credits.ts
Normal file
35
packages/db/src/Credits.ts
Normal 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");
|
||||
15
packages/db/src/DiscordUsers.ts
Normal file
15
packages/db/src/DiscordUsers.ts
Normal 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");
|
||||
@@ -12,6 +12,7 @@ export interface PresenceSchema {
|
||||
|
||||
export interface PresenceMetadata {
|
||||
$schema: string;
|
||||
apiVersion: number;
|
||||
altnames?: string[];
|
||||
author: PresenceMetadataContributor;
|
||||
category: PresenceMetadataCategory;
|
||||
@@ -82,6 +83,7 @@ const PresenceMetadataSchema = new Schema<PresenceMetadata>({
|
||||
thumbnail: { required: true, type: String },
|
||||
url: { required: true, type: [String] },
|
||||
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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
1610
pnpm-lock.yaml
generated
1610
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,9 @@
|
||||
},
|
||||
{
|
||||
"path": "./apps/api-worker/tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./apps/discord-bot/tsconfig.app.json"
|
||||
}
|
||||
],
|
||||
"files": []
|
||||
|
||||
@@ -29,5 +29,5 @@
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"exclude": ["**/*/node_modules", "**/*/dist"]
|
||||
"exclude": ["**/*/node_modules", "**/*/dist", "**/drizzle.config.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user