Compare commits
259 Commits
main
...
api-worker
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f339035463 | ||
|
|
b67226dcdd | ||
|
|
154acbc802 | ||
|
|
a1ed87eccf | ||
|
|
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 | ||
|
|
c46cf6975a | ||
|
|
68c6b4fcdc | ||
|
|
55fa07d5b5 | ||
|
|
903c238b33 | ||
|
|
acd9afb2b1 | ||
|
|
4bd42390eb | ||
|
|
c014504464 | ||
|
|
24fe349b60 | ||
|
|
ee5428ce08 | ||
|
|
e4b1010160 | ||
|
|
34c42d59ed | ||
|
|
d9267361aa | ||
|
|
0d5382fd50 | ||
|
|
e9015b1204 | ||
|
|
cea36426ab | ||
|
|
48c141094e | ||
|
|
e67fb97e14 | ||
|
|
0bd0d759f6 | ||
|
|
60b7f63409 | ||
|
|
78b482be4f | ||
|
|
9db9e931b6 | ||
|
|
665263e9b5 | ||
|
|
60257dbe53 | ||
|
|
411a70f567 | ||
|
|
4d1b092ee5 | ||
|
|
aa41f1cdae | ||
|
|
04b5d54697 | ||
|
|
fb096bc4be | ||
|
|
6e9e4ae1b6 | ||
|
|
d8f73202b9 | ||
|
|
b175a08dce | ||
|
|
2284ee94ad | ||
|
|
78a3311342 | ||
|
|
a1fabd3fd6 | ||
|
|
93c62cc38f | ||
|
|
8553613593 | ||
|
|
bf83dc4452 | ||
|
|
91bf2237c2 | ||
|
|
ae9b579e84 | ||
|
|
2488d98ede | ||
|
|
3eedb8ba81 | ||
|
|
bc5cc3054b | ||
|
|
5340b6ec2e | ||
|
|
0a28b2181b | ||
|
|
389ee87757 | ||
|
|
f3976f5aaf | ||
|
|
e85015725d | ||
|
|
a624b7a1cb | ||
|
|
6c85d66eab | ||
|
|
175f5ec430 | ||
|
|
18dc9fd594 | ||
|
|
6dd3c5ffcb | ||
|
|
dad3fbf44d | ||
|
|
c42fbfe8b0 | ||
|
|
3a30457944 | ||
|
|
2eae287e18 | ||
|
|
457882c515 | ||
|
|
224925c772 | ||
|
|
b5fca1c943 | ||
|
|
265a4ffffd | ||
|
|
ec5ff30ef3 | ||
|
|
947b3b91a9 | ||
|
|
71eefae4b4 | ||
|
|
6800649154 | ||
|
|
2100e4420b | ||
|
|
d4fc4c97ce | ||
|
|
ad658b1433 | ||
|
|
40c94fa8e5 | ||
|
|
902b1e4887 | ||
|
|
2e98a5a24e | ||
|
|
fbb9c815df | ||
|
|
0ed9f5b264 | ||
|
|
692fb2a8a5 | ||
|
|
151f7df388 | ||
|
|
9281ce0984 | ||
|
|
a82d435e22 | ||
|
|
6deda1ec8f | ||
|
|
258585efa0 | ||
|
|
2d1eaafbf9 | ||
|
|
5837c5be5b | ||
|
|
2106300635 | ||
|
|
82e7a260fb | ||
|
|
64f5cd6c12 | ||
|
|
8e56c12b29 | ||
|
|
febb0e1927 | ||
|
|
4ea97b6b42 | ||
|
|
8322b09221 | ||
|
|
854cb4145c | ||
|
|
c36f60d960 | ||
|
|
6bf1d58daa | ||
|
|
1c4cb32a6e | ||
|
|
009384a20d | ||
|
|
6e17a2d382 | ||
|
|
0e75dab8ff | ||
|
|
517119365e | ||
|
|
3e15ddf988 | ||
|
|
fb279a418a | ||
|
|
3e86393b25 | ||
|
|
14c476101b | ||
|
|
10cdf6ff47 | ||
|
|
975c354617 | ||
|
|
744a252469 | ||
|
|
e0305773c7 | ||
|
|
af25311e36 | ||
|
|
0877e706d2 | ||
|
|
ec516f2d02 | ||
|
|
311b1d96fa | ||
|
|
be181718e3 | ||
|
|
85ec19bda3 | ||
|
|
6314acbf81 | ||
|
|
c8136da505 | ||
|
|
3a41060d91 | ||
|
|
9e12a9f84f | ||
|
|
664e5c3e73 | ||
|
|
34bf4daa6a | ||
|
|
b2f4051e11 | ||
|
|
03cc674601 | ||
|
|
da3d007a35 | ||
|
|
80d9888b37 | ||
|
|
3ae265d6fe | ||
|
|
3c9e7708f8 | ||
|
|
7663eacb58 | ||
|
|
85d5fa14fa | ||
|
|
2b55d34c19 | ||
|
|
9d1c2167be | ||
|
|
ce52ce9a97 | ||
|
|
d73db7e5a9 | ||
|
|
98ce2ff939 | ||
|
|
f98d615e7b | ||
|
|
9c461c83ff | ||
|
|
dc8dacd5f5 | ||
|
|
db47ae9043 | ||
|
|
a0f4e141b5 | ||
|
|
5bc4c0e5c0 | ||
|
|
c4765b8eeb | ||
|
|
ebc953b549 | ||
|
|
b407003450 | ||
|
|
3857702040 | ||
|
|
e997e946a0 | ||
|
|
2bcb87ec9c | ||
|
|
1d5af93390 | ||
|
|
8a0e10eab0 | ||
|
|
13e413329c | ||
|
|
d0fac8c495 | ||
|
|
74525de586 | ||
|
|
115923b935 | ||
|
|
b7af6bb80e | ||
|
|
b053e6395c | ||
|
|
698ea34848 | ||
|
|
d689324e62 | ||
|
|
fee8133965 | ||
|
|
b93db93236 | ||
|
|
1efa4b9bb0 | ||
|
|
4cf00f5ca9 | ||
|
|
94303a9bca | ||
|
|
fa611923b4 | ||
|
|
c57d4db5a3 | ||
|
|
5d7760caa3 | ||
|
|
0d5fe8e5ab | ||
|
|
1ac552f3cd | ||
|
|
4cf4e01286 | ||
|
|
e96e2c0cb6 | ||
|
|
8fb93a09f9 | ||
|
|
4d20ba95b1 | ||
|
|
85f428a295 | ||
|
|
a3018f528d | ||
|
|
3d8431c4d5 | ||
|
|
074d55b6d4 | ||
|
|
d22d88b3a6 | ||
|
|
f55a83977b | ||
|
|
4ccc02f16c | ||
|
|
759b2abef9 |
1
.devcontainer/Dockerfile
Normal file
@@ -0,0 +1 @@
|
||||
FROM mcr.microsoft.com/devcontainers/base:bullseye
|
||||
24
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,24 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose
|
||||
{
|
||||
"name": "PreMiD",
|
||||
"dockerComposeFile": ["docker-compose.yml"],
|
||||
"service": "app",
|
||||
"workspaceFolder": "/workspaces",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "lts",
|
||||
"nvmVersion": "latest"
|
||||
},
|
||||
"ghcr.io/joshuanianji/devcontainer-features/mount-pnpm-store:1": {},
|
||||
"ghcr.io/dhoeric/features/act:1": {}
|
||||
},
|
||||
"overrideFeatureInstallOrder": ["ghcr.io/devcontainers/features/node:1", "ghcr.io/joshuanianji/devcontainer-features/mount-pnpm-store:1"],
|
||||
"postCreateCommand": "pnpm i --frozen-lockfile",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": ["Gruntfuggly.todo-tree", "YoavBls.pretty-ts-errors", "EditorConfig.EditorConfig", "DeepScan.vscode-deepscan", "esbenp.prettier-vscode"]
|
||||
}
|
||||
},
|
||||
"shutdownAction": "stopCompose"
|
||||
}
|
||||
32
.devcontainer/docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
# Update this to the name of the service you want to work with in your docker-compose.yml file
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
# Uncomment if you want to override the service's Dockerfile to one in the .devcontainer
|
||||
# folder. Note that the path of the Dockerfile and context is relative to the *primary*
|
||||
# docker-compose.yml file (the first in the devcontainer.json "dockerComposeFile"
|
||||
# array). The sample below assumes your primary file is in the root of your project.
|
||||
#
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: .devcontainer/Dockerfile
|
||||
|
||||
volumes:
|
||||
# Update this to wherever you want VS Code to mount the folder of your project
|
||||
- ..:/workspaces:cached
|
||||
|
||||
# Uncomment the next four lines if you will use a ptrace-based debugger like C++, Go, and Rust.
|
||||
# cap_add:
|
||||
# - SYS_PTRACE
|
||||
# security_opt:
|
||||
# - seccomp:unconfined
|
||||
|
||||
# Overrides default command so things don't shut down after the process ends.
|
||||
command: /bin/sh -c "while sleep 1000; do :; done"
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
.vscode
|
||||
.DS_Store
|
||||
.Trashes
|
||||
.nuxt
|
||||
.output
|
||||
dist
|
||||
node_modules
|
||||
.env
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
generated
|
||||
13
.gitattributes
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
* text eol=lf
|
||||
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.mp4 binary
|
||||
*.mp3 binary
|
||||
*.gz binary
|
||||
*.zip binary
|
||||
*.ttf binary
|
||||
*.woff binary
|
||||
30
.github/CONTRIBUTING.md
vendored
@@ -1,30 +0,0 @@
|
||||
# Contributing
|
||||
|
||||
## Requiered knowledge
|
||||
|
||||
- JavaScript
|
||||
- html5
|
||||
- NodeJS
|
||||
|
||||
Additional:
|
||||
|
||||
- CSS
|
||||
- [VueJS](https://vuejs.org/)
|
||||
- [ElectronJS](https://electronjs.org/)
|
||||
- [NPMjs](https://www.npmjs.com/)
|
||||
|
||||
A source code editor is also requiered. We recommend [Visual Studio Code](https://code.visualstudio.com/).
|
||||
|
||||
### Installing the components
|
||||
|
||||
1. Install [Git](https://git-scm.com/)
|
||||
2. Install [Node](https://nodejs.org/en/)
|
||||
|
||||
### Cloning the project
|
||||
|
||||
1. Fork the [repository](https://github.com/PreMiD/PreMiD)
|
||||
2. Open a terminal and type `git clone https://github.com/PreMiD/PreMiD`
|
||||
|
||||
### Coding your vision
|
||||
|
||||
Please keep the structure. We don't want to disorganize our project. Chaotic files may not be accepted.
|
||||
BIN
.github/Electron/Chrome_bsp.png
vendored
|
Before Width: | Height: | Size: 332 KiB |
BIN
.github/Electron/PMD_Banner.png
vendored
|
Before Width: | Height: | Size: 682 KiB |
2
.github/FUNDING.yml
vendored
@@ -1,3 +1,3 @@
|
||||
github: Timeraa
|
||||
github: [PreMiD, Timeraa]
|
||||
patreon: Timeraa
|
||||
ko_fi: Timeraa
|
||||
|
||||
31
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,31 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
---
|
||||
|
||||
<!-- NOTE -->
|
||||
<!-- Keep all presence related bugs in our Presences repository! -->
|
||||
<!-- https://github.com/PreMiD/Presences/issues -->
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
16
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,16 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for PreMiD
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
BIN
.github/Logo.png
vendored
|
Before Width: | Height: | Size: 11 KiB |
BIN
.github/Patreon.png
vendored
|
Before Width: | Height: | Size: 3.0 KiB |
1
.github/PayPal.svg
vendored
|
Before Width: | Height: | Size: 5.1 KiB |
13
.github/SUPPORT.md
vendored
@@ -1,13 +0,0 @@
|
||||
# How to get support
|
||||
|
||||
## Take a look at the [wiki](https://wiki.premid.app)
|
||||
Our GitHub wiki is full of information around PreMiD.<br>
|
||||
Take a look and feel free to contribute if you want to add something new.
|
||||
|
||||
## [Open a issue](https://github.com/PreMiD/PreMiD/issues/new/choose) on [GitHub](https://github.com/PreMiD/PreMiD)
|
||||
Simply open a issue if you don't feel allright.<br>
|
||||
*Aand there he goes...*
|
||||
|
||||
## Ask a staff member in [#support](https://discord.premid.app)
|
||||
The team is ready to tell you the secrets of the underworld.<br>
|
||||
Join our [Discord server](https://discord.premid.app) and find out what we're hiding.
|
||||
BIN
.github/TwitterButton.png
vendored
|
Before Width: | Height: | Size: 4.1 KiB |
36
.github/actions/build-and-push-docker/action.yaml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Build and Push Docker Image
|
||||
description: Builds a Docker image and pushes it to GitHub Container Registry
|
||||
inputs:
|
||||
app:
|
||||
description: Name of the app
|
||||
required: true
|
||||
token:
|
||||
description: GitHub token
|
||||
required: true
|
||||
outputs:
|
||||
version:
|
||||
description: Version of the app
|
||||
value: ${{ steps.get_version.outputs.version }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get package.json version
|
||||
id: get_version
|
||||
run: echo ::set-output name=version::$(node -p "require('./apps/${{ inputs.app }}/package.json').version")
|
||||
shell: bash
|
||||
|
||||
- name: Convert repository owner to lowercase
|
||||
id: repo
|
||||
run: echo "::set-output name=lowercase::$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')"
|
||||
shell: bash
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
uses: premid/premid/.github/actions/build-docker@monorepo
|
||||
with:
|
||||
dockerfile: ./apps/${{ inputs.app }}/Dockerfile
|
||||
push: true
|
||||
token: ${{ inputs.token }}
|
||||
tags: ghcr.io/${{ steps.repo.outputs.lowercase }}/${{ inputs.app }}:${{ steps.get_version.outputs.version }},ghcr.io/${{ steps.repo.outputs.lowercase }}/${{ inputs.app }}:latest
|
||||
46
.github/actions/build-docker/action.yaml
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
name: Build Docker Image
|
||||
description: Builds a Docker image using Docker Buildx
|
||||
inputs:
|
||||
dockerfile:
|
||||
description: Path to the Dockerfile
|
||||
required: true
|
||||
tags:
|
||||
description: Comma-separated list of tags for the Docker image
|
||||
required: true
|
||||
push:
|
||||
description: Whether to push the Docker image to the registry
|
||||
required: false
|
||||
default: "false"
|
||||
token:
|
||||
description: GitHub Token
|
||||
required: false
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ inputs.push == 'true' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ inputs.token }}
|
||||
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ inputs.dockerfile }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ inputs.push }}
|
||||
tags: ${{ inputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
20
.github/dependabot.yml
vendored
@@ -1,20 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
commit-message:
|
||||
prefix: chore
|
||||
include: scope
|
||||
labels:
|
||||
- "dependencies"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
commit-message:
|
||||
prefix: chore
|
||||
include: scope
|
||||
labels:
|
||||
- "dependencies"
|
||||
BIN
.github/example.png
vendored
|
Before Width: | Height: | Size: 332 KiB |
5
.github/renovate.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["github>Recodive/Recodive:renovate-config"],
|
||||
"automerge": false
|
||||
}
|
||||
69
.github/workflows/cd.yaml
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
name: CD
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- monorepo
|
||||
tags:
|
||||
- "*"
|
||||
permissions:
|
||||
packages: write
|
||||
jobs:
|
||||
build:
|
||||
name: Build Docker Images
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target:
|
||||
- pd
|
||||
- schema-server
|
||||
- website
|
||||
- api-worker
|
||||
- api-master
|
||||
- discord-bot
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push website
|
||||
uses: docker/build-push-action@v6
|
||||
if: matrix.target == 'website'
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: SERVICE=${{ matrix.target }}
|
||||
target: website
|
||||
tags: ghcr.io/premid/${{ matrix.target }}:beta-${{ github.sha }}-${{ github.run_number }}
|
||||
|
||||
- name: Get package.json version
|
||||
if: matrix.target != 'website'
|
||||
id: get_version
|
||||
run: echo ::set-output name=version::$(node -p "require('./apps/${{ matrix.target }}/package.json').version")
|
||||
shell: bash
|
||||
|
||||
- name: Build and push other images
|
||||
uses: docker/build-push-action@v6
|
||||
if: matrix.target != 'website' && startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
target: prod
|
||||
build-args: SERVICE=${{ matrix.target }}
|
||||
tags: ghcr.io/premid/${{ matrix.target }}:latest,ghcr.io/premid/${{ matrix.target }}:${{ steps.get_version.outputs.version }}
|
||||
72
.github/workflows/ci.yaml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
name: Build, Lint and Test
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- monorepo
|
||||
pull_request:
|
||||
jobs:
|
||||
build:
|
||||
name: Build, Lint and Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: pnpm
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Codegen
|
||||
run: pnpm -r codegen
|
||||
|
||||
- name: Lint
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
build-docker:
|
||||
name: Build Docker Images
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target:
|
||||
- pd
|
||||
- schema-server
|
||||
- api-worker
|
||||
- api-master
|
||||
- website
|
||||
- discord-bot
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Get Target
|
||||
id: get_target
|
||||
run: echo "target=$([[ ${{ matrix.target }} == 'website' ]] && echo 'website' || echo 'prod')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
target: ${{ steps.get_target.outputs.target }}
|
||||
build-args: SERVICE=${{ matrix.target }}
|
||||
98
.github/workflows/deploy.yml
vendored
@@ -1,98 +0,0 @@
|
||||
name: DePloY
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
env:
|
||||
NODE_ENV: DePloY
|
||||
jobs:
|
||||
package:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macOS-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- uses: actions/setup-node@master
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
npm i
|
||||
npm i -g typescript rimraf
|
||||
- name: Prepare to package
|
||||
run: npm run init
|
||||
- name: Package
|
||||
run: |
|
||||
npm run pkg
|
||||
rimraf dist/app
|
||||
node util/zip dist ${{ matrix.os }}.zip --zip
|
||||
- name: Upload bundle
|
||||
env:
|
||||
SSHHOST: ${{ secrets.MAIN_HOST }}
|
||||
SSH_USERNAME: ${{ secrets.SSH_USERNAME }}
|
||||
SSH_KEY: ${{ secrets.SSH_KEY }}
|
||||
run: |
|
||||
tsc util/uploadFile util/zip
|
||||
node util/uploadFile ${{ matrix.os }}.zip /home/PreMiD/download/util/${{ matrix.os }}.zip
|
||||
createInstallers:
|
||||
runs-on: "ubuntu-latest"
|
||||
needs: package
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- uses: actions/setup-node@master
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo npm i
|
||||
sudo npm i -g typescript
|
||||
- name: Download InstallBuilder
|
||||
run: |
|
||||
wget https://clients.bitrock.com/installbuilder/installbuilder-enterprise-20.12.0-linux-x64-installer.run
|
||||
chmod u+x installbuilder-enterprise-20.12.0-linux-x64-installer.run
|
||||
- name: Install InstallBuilder
|
||||
run: |
|
||||
./installbuilder-enterprise-20.12.0-linux-x64-installer.run --installer-language en --prefix ./installbuilder --mode unattended
|
||||
echo "${{ secrets.IBLICENSE }}" > ./installbuilder/license.xml
|
||||
- name: Prepare Upgrade Installer
|
||||
run: |
|
||||
tsc util/prepare
|
||||
node util/prepare
|
||||
- name: Create Upgrade Installer (MacOS 64bit)
|
||||
run: |
|
||||
installbuilder/bin/builder build installer_assets/PreMiD-Upgrade.xml osx
|
||||
- name: Create Upgrade Installer (Windows)
|
||||
run: |
|
||||
installbuilder/bin/builder build installer_assets/PreMiD-Upgrade.xml windows
|
||||
- name: Upload files
|
||||
env:
|
||||
SSHHOST: ${{ secrets.MAIN_HOST }}
|
||||
SSH_USERNAME: ${{ secrets.SSH_USERNAME }}
|
||||
SSH_KEY: ${{ secrets.SSH_KEY }}
|
||||
run: |
|
||||
tsc util/uploadFile util/zip
|
||||
node util/uploadFile dist/installer/upgrader.exe /home/PreMiD/download/upgrader.exe
|
||||
node util/uploadFile dist/installer/upgrader.app.zip /home/PreMiD/download/util/upgrader.app.zip
|
||||
- name: Finalize build
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: ${{ secrets.MAIN_HOST }}
|
||||
username: ${{ secrets.SSH_USERNAME }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
script: |
|
||||
cd /home/PreMiD/download/util
|
||||
unzip upgrader.app.zip
|
||||
tar -czvf upgrader.app.tgz upgrader.app
|
||||
mv upgrader.app.tgz ../
|
||||
rm -rf upgrader.app upgrader.app.zip
|
||||
unzip windows-latest.zip
|
||||
cd windows-latest/PreMiD-win32-x64/
|
||||
zip -r ../../PreMiD-win32-x64.zip .
|
||||
mv ../../PreMiD-win32-x64.zip /home/PreMiD/download/
|
||||
cd ../PreMiD-win32-ia32/
|
||||
zip -r ../../PreMiD-win32-x86.zip .
|
||||
mv ../../PreMiD-win32-x86.zip /home/PreMiD/download/
|
||||
cd ../..
|
||||
rm -rf windows-latest windows-latest.zip
|
||||
unzip macOS-latest.zip
|
||||
cd macOS-latest/PreMiD-darwin-x64/
|
||||
zip -r ../../PreMiD-darwin-x64.zip .
|
||||
mv ../../PreMiD-darwin-x64.zip /home/PreMiD/download/
|
||||
cd ../..
|
||||
rm -rf macOS-latest macOS-latest.zip
|
||||
8
.gitignore
vendored
@@ -2,6 +2,8 @@ node_modules
|
||||
out
|
||||
dist
|
||||
tmp
|
||||
lib
|
||||
data
|
||||
|
||||
.vscode
|
||||
.env
|
||||
@@ -18,3 +20,9 @@ src/update.ini
|
||||
*.app
|
||||
*.xml.backup
|
||||
*.js
|
||||
!*.config.js
|
||||
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
*.log
|
||||
2
.husky/commit-msg
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
pnpm exec commitlint --edit $1
|
||||
3
.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
||||
*.js
|
||||
*.ts
|
||||
*.json
|
||||
22
@types/PreMiD/ExtensionSettings.d.ts
vendored
@@ -1,22 +0,0 @@
|
||||
export default interface ExtensionSettings {
|
||||
/**
|
||||
* If extension is enabled
|
||||
*/
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Autolaunch enabled
|
||||
*/
|
||||
autoLaunch: boolean;
|
||||
/**
|
||||
* Media keys enabled
|
||||
*/
|
||||
mediaKeys: boolean;
|
||||
/**
|
||||
* title menubar (TrayTitle)
|
||||
*/
|
||||
titleMenubar: boolean;
|
||||
/**
|
||||
* language of extension
|
||||
*/
|
||||
language: string;
|
||||
}
|
||||
16
@types/PreMiD/Presence.d.ts
vendored
@@ -1,16 +0,0 @@
|
||||
import * as Discord from "discord-rpc";
|
||||
|
||||
export default interface Presence {
|
||||
/**
|
||||
* Client ID of presence
|
||||
*/
|
||||
clientId: string;
|
||||
/**
|
||||
* Rich Procedual call connection
|
||||
*/
|
||||
rpc: Discord.Client;
|
||||
/**
|
||||
* Connection ready?
|
||||
*/
|
||||
ready: Boolean;
|
||||
}
|
||||
33
@types/PreMiD/PresenceData.d.ts
vendored
@@ -1,33 +0,0 @@
|
||||
import * as Discord from "discord-rpc";
|
||||
|
||||
export default interface PresenceData {
|
||||
/**
|
||||
* Client ID of presence
|
||||
*/
|
||||
clientId: string;
|
||||
/**
|
||||
* Tray title to be shown in Mac OS tray
|
||||
*/
|
||||
trayTitle: string;
|
||||
/**
|
||||
* service name of presence
|
||||
* @deprecated
|
||||
*/
|
||||
service: string;
|
||||
/**
|
||||
* Determines if the service is currently playing something back or not, if false it will automatically hide after 1 minute
|
||||
*/
|
||||
playback: boolean;
|
||||
/**
|
||||
* Discord Presence which gets sent directly to Discord app
|
||||
*/
|
||||
presenceData: Discord.Presence;
|
||||
/**
|
||||
* Determines if the service should be hidden (clearActivity)
|
||||
*/
|
||||
hidden: boolean;
|
||||
/**
|
||||
* Determines if the service is mediaKey able / uses them
|
||||
*/
|
||||
mediaKeys: boolean;
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
* @Timeraa
|
||||
* @Timeraa
|
||||
@@ -2,65 +2,38 @@
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, sex characteristics, gender identity and expression,
|
||||
level of experience, education, socio-economic status, nationality, personal
|
||||
appearance, race, religion, or sexual identity and orientation.
|
||||
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
Examples of behavior that contributes to creating a positive environment include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
- Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
- The use of sexualized language or imagery and unwelcome sexual attention or advances
|
||||
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or electronic address, without explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behavior.
|
||||
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or
|
||||
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||
permanently any contributor for other behaviors that they deem inappropriate,
|
||||
threatening, offensive, or harmful.
|
||||
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community. Examples of
|
||||
representing a project or community include using an official project e-mail
|
||||
address, posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting the project team at contact@premid.app or by contacting a staff member on our [Discord server](https://discord.premid.app). All
|
||||
complaints will be reviewed and investigated and will result in a response that
|
||||
is deemed necessary and appropriate to the circumstances. The project team is
|
||||
obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||
Further details of specific enforcement policies may be posted separately.
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at contact@premid.app or by contacting a staff member on our [Discord server](https://discord.premid.app). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||
faith may face temporary or permanent repercussions as determined by other
|
||||
members of the project's leadership.
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
|
||||
|
||||
36
Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
||||
FROM gplane/pnpm:node20-alpine AS base
|
||||
RUN corepack enable
|
||||
ARG SERVICE
|
||||
|
||||
FROM base AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY . /app
|
||||
|
||||
RUN pnpm i --frozen-lockfile
|
||||
|
||||
RUN if [ "$SERVICE" != "website" ]; then pnpm run -r codegen; fi
|
||||
RUN if [ "$SERVICE" != "website" ]; then pnpm run build; fi
|
||||
RUN if [ "$SERVICE" == "website" ]; then pnpm --filter @premid/website run build; fi
|
||||
RUN if [ "$SERVICE" != "website" ]; then pnpm --filter @premid/${SERVICE} deploy --prod /prod/${SERVICE}; fi
|
||||
|
||||
FROM node:20-alpine AS prod
|
||||
ARG SERVICE
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /prod/${SERVICE} ./
|
||||
ENV PORT=80
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["npm", "start"]
|
||||
|
||||
FROM node:20-alpine AS website
|
||||
WORKDIR /app
|
||||
ENV PORT=80
|
||||
|
||||
COPY --from=build /app/apps/website/.output /app
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["node", "server/index.mjs"]
|
||||
68
README.md
@@ -1,46 +1,54 @@
|
||||
<div align="center">
|
||||
|
||||
<img src=".github/Logo.png" width="150px" draggable="false"><br>
|
||||
<img src="https://cdn.rcd.gg/PreMiD.png" width="150px" />
|
||||
|
||||
# PreMiD
|
||||
|
||||
## Your Rich Presence for web services!
|
||||
[](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/PreMiD/PreMiD)
|
||||
[](https://crowdin.com/project/premid)
|
||||
|
||||

|
||||

|
||||

|
||||
[](https://chrome.google.com/webstore/detail/premid/agjnjboanicjcpenljmaaigopkgdnihi)
|
||||

|
||||
[](https://app.fossa.io/projects/git%2Bgithub.com%2FPreMiD%2FPreMiD?ref=badge_shield)
|
||||
This is the monorepo for PreMiD. PreMiD is a simple, configurable utility that allows you to show what you're watching/listening to on your Discord profile.
|
||||
|
||||
<img src=".github/example.png" draggable="false"><br>
|
||||
## Getting Started
|
||||
|
||||
# About
|
||||
**If you are a user looking to install PreMiD, please visit the [official website](https://premid.app).**
|
||||
|
||||
**PreMiD** is a simple, configurable utility that allows you to show what you're doing on the web in your Discord **now playing status**. It supports many different websites, and will support multiple users watching the same content simultaneously in an upcoming update.
|
||||
If you are a developer looking to contribute to PreMiD, read along.
|
||||
|
||||
# Features
|
||||
## Table of Contents
|
||||
|
||||
· Displays your current web service in Discord as your status.<br>
|
||||
· Grants full control over Presences.<br>
|
||||
· Supports over 1,000 web services, still rising!<br>
|
||||
· _Watch parties and more are coming soon!_
|
||||
- [Packages](#packages)
|
||||
- [License](#license)
|
||||
|
||||
# Installation/Troubleshooting
|
||||
## Packages
|
||||
|
||||
### Installation instructions, Troubleshooting guides etc. can be located at our [**docs**](https://docs.premid.app).
|
||||
This monorepo is split into multiple packages / projects. Here's a list of them:
|
||||
|
||||
# Support us
|
||||
- [apps/api](apps/api) - The API for PreMiD.
|
||||
- [apps/website](apps/website) - The website for PreMiD.
|
||||
- [apps/docs](apps/docs) - The official documentation for PreMiD.
|
||||
- [apps/pd](apps/pd/README.md) - A simple url shortener service to shorten urls longer than 256 characters.
|
||||
- [apps/schema-server](apps/schema-server) - Simple Schema server for the Presence manifest.
|
||||
- [packages/db](packages/db) - Database schema for PreMiD.
|
||||
|
||||
<div>
|
||||
<a target="_blank" href="https://www.patreon.com/bePatron?u=4610890" data-patreon-widget-type="become-patron-button" title="Support me on Patreon!">
|
||||
<img height="75px" draggable="false" src=".github/Patreon.png">
|
||||
</a>
|
||||
<a target="_blank" href="https://discord.premid.app/" title="Join our Discord!">
|
||||
<img src="https://discordapp.com/api/guilds/493130730549805057/widget.png?style=banner2" height="76px" draggable="false" alt="Join our Discord!">
|
||||
</a>
|
||||
</div>
|
||||
## Development
|
||||
|
||||
### Release
|
||||
|
||||
To release a new version of a package, run the following command:
|
||||
|
||||
```bash
|
||||
cd apps/<app>
|
||||
pnpm bumpp -y -t <app>-v
|
||||
```
|
||||
|
||||
Replace `<app>` with the name of the package you want to release. For example, to release a new version of the `schema-server` package, you would run:
|
||||
|
||||
```bash
|
||||
cd apps/schema-server
|
||||
pnpm bumpp -y -t schema-server-v
|
||||
```
|
||||
|
||||
This will use bumpp to bump the version of the package in the `package.json` file, create a tag for the new version, and push the changes to the remote repository.
|
||||
|
||||
## License
|
||||
|
||||
[](https://app.fossa.io/projects/git%2Bgithub.com%2FPreMiD%2FPreMiD?ref=badge_large)
|
||||
This project is licensed under the [MPL-2.0 License](LICENSE).
|
||||
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
37
apps/api-master/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@premid/api-master",
|
||||
"type": "module",
|
||||
"version": "0.0.37",
|
||||
"private": true,
|
||||
"description": "PreMiD's api master",
|
||||
"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 .",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:update": "pnpm db:generate && pnpm db:migrate",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@envelop/sentry": "^9.0.0",
|
||||
"@sentry/node": "^8.17.0",
|
||||
"cron": "^3.1.7",
|
||||
"debug": "^4.3.6",
|
||||
"drizzle-orm": "^0.33.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"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",
|
||||
"drizzle-kit": "^0.24.2"
|
||||
}
|
||||
}
|
||||
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
@@ -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)}`));
|
||||
}
|
||||
100
apps/api-master/src/functions/clearOldSessions.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import pLimit from "p-limit";
|
||||
import ky, { HTTPError, TimeoutError } from "ky";
|
||||
import { mainLog, redis } from "../index.js";
|
||||
|
||||
let inProgress = false;
|
||||
export async function clearOldSessions() {
|
||||
if (inProgress) {
|
||||
mainLog("Session cleanup already in progress");
|
||||
return;
|
||||
}
|
||||
|
||||
inProgress = true;
|
||||
const now = Date.now();
|
||||
const pattern = "pmd-api.sessions.*";
|
||||
let cursor = "0";
|
||||
let totalSessions = 0;
|
||||
let cleared = 0;
|
||||
const batchSize = 100;
|
||||
let keysToDelete: string[] = [];
|
||||
|
||||
mainLog("Starting session cleanup");
|
||||
|
||||
const limit = pLimit(100); // Create a limit of 100 concurrent operations
|
||||
|
||||
do {
|
||||
const [newCursor, keys] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 1000); //* Use SCAN with COUNT for memory efficiency
|
||||
|
||||
cursor = newCursor;
|
||||
totalSessions += keys.length;
|
||||
|
||||
const deletePromises: Promise<string>[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const session = await redis.hgetall(key) as unknown as {
|
||||
token: string;
|
||||
session: string;
|
||||
lastUpdated: number;
|
||||
};
|
||||
|
||||
if (now - session.lastUpdated < 30000)
|
||||
continue;
|
||||
|
||||
deletePromises.push(limit(() => deleteSession(session, key)));
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(deletePromises);
|
||||
results.forEach((result) => {
|
||||
if (result.status === "fulfilled" && result.value) {
|
||||
keysToDelete.push(result.value);
|
||||
cleared++;
|
||||
}
|
||||
});
|
||||
|
||||
if (keysToDelete.length >= batchSize) {
|
||||
await redis.del(...keysToDelete);
|
||||
keysToDelete = [];
|
||||
}
|
||||
} while (cursor !== "0");
|
||||
|
||||
if (keysToDelete.length > 0) {
|
||||
await redis.del(...keysToDelete);
|
||||
}
|
||||
|
||||
if (totalSessions === 0) {
|
||||
mainLog("No sessions to clear");
|
||||
}
|
||||
else {
|
||||
mainLog(`Checked ${totalSessions} sessions, cleared ${cleared}`);
|
||||
}
|
||||
|
||||
inProgress = false;
|
||||
}
|
||||
|
||||
async function deleteSession(session: { token: string; session: string }, key: string): Promise<string> {
|
||||
try {
|
||||
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,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
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 for key ${key}: Unknown error`);
|
||||
}
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
30
apps/api-master/src/functions/createRedis.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { hostname } from "node:os";
|
||||
import process from "node:process";
|
||||
|
||||
import { Redis } from "ioredis";
|
||||
|
||||
/* c8 ignore start */
|
||||
export default function createRedis(): Redis {
|
||||
const redis = new Redis({
|
||||
connectionName: `api-master-${hostname()}-${process.pid.toString()}`,
|
||||
lazyConnect: true,
|
||||
name: "mymaster",
|
||||
sentinels: process.env.REDIS_SENTINELS?.split(",").map(s => ({
|
||||
host: s,
|
||||
port: 26_379,
|
||||
})),
|
||||
});
|
||||
|
||||
/* c8 ignore next 3 */
|
||||
redis.on("error", (error) => {
|
||||
console.error("Redis error", error);
|
||||
});
|
||||
|
||||
/* c8 ignore next 4 */
|
||||
redis.on("connect", () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Redis connected");
|
||||
});
|
||||
|
||||
return redis;
|
||||
}
|
||||
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
@@ -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;
|
||||
}
|
||||
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
@@ -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");
|
||||
});
|
||||
}
|
||||
44
apps/api-master/src/index.ts
Normal file
@@ -0,0 +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 "./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 jobs");
|
||||
|
||||
void reloadIpLocationApi();
|
||||
|
||||
void new CronJob(
|
||||
// Every 5 seconds
|
||||
"*/5 * * * * *",
|
||||
() => {
|
||||
if (process.env.DISABLE_CLEAR_OLD_SESSIONS !== "true") {
|
||||
clearOldSessions();
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
void new CronJob(
|
||||
// Every day at 1am
|
||||
"0 1 * * *",
|
||||
() => {
|
||||
cleanupOldUserData(14); // Keep 14 days of data
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
41
apps/api-master/src/tracing.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
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 scanCount = Number.parseInt(process.env.SCAN_COUNT || "1000", 10);
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
register.registerMetric(activeSessionsCounter);
|
||||
register.registerMetric(activePresencesCounter);
|
||||
10
apps/api-master/tsconfig.app.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"rootDir": "src",
|
||||
"types": ["./environment.d.ts"],
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
8
apps/api-master/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["environment.d.ts", "src", "codegen.ts"]
|
||||
}
|
||||
1
apps/api-worker/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
generated
|
||||
26
apps/api-worker/codegen.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { CodegenConfig } from "@graphql-codegen/cli";
|
||||
|
||||
const config: CodegenConfig = {
|
||||
generates: {
|
||||
"dist/generated/schema-v5.graphql": {
|
||||
plugins: ["schema-ast"],
|
||||
schema: "src/graphql/schema/v5/**/*.gql",
|
||||
},
|
||||
"src/generated/graphql-v5.ts": {
|
||||
config: {
|
||||
scalars: {
|
||||
StringOrStringArray: "string | string[]",
|
||||
},
|
||||
},
|
||||
plugins: ["typescript", "typescript-resolvers"],
|
||||
schema: "src/graphql/schema/v5/**/*.gql",
|
||||
},
|
||||
"src/generated/schema-v5.graphql": {
|
||||
plugins: ["schema-ast"],
|
||||
schema: "src/graphql/schema/v5/**/*.gql",
|
||||
},
|
||||
},
|
||||
overwrite: true,
|
||||
};
|
||||
|
||||
export default config;
|
||||
7
apps/api-worker/environment.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
declare namespace NodeJS {
|
||||
export interface ProcessEnv {
|
||||
NODE_ENV?: "development" | "production" | "test";
|
||||
DATABASE_URL?: string;
|
||||
SESSION_KEEP_ALIVE_INTERVAL?: string;
|
||||
}
|
||||
}
|
||||
49
apps/api-worker/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@premid/api-worker",
|
||||
"type": "module",
|
||||
"version": "0.0.18",
|
||||
"private": true,
|
||||
"description": "PreMiD's api",
|
||||
"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 .",
|
||||
"build": "pnpm codegen",
|
||||
"codegen": "graphql-codegen --config codegen.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordjs/rest": "^2.3.0",
|
||||
"@envelop/sentry": "^9.0.0",
|
||||
"@escape.tech/graphql-armor-max-aliases": "^2.5.0",
|
||||
"@escape.tech/graphql-armor-max-depth": "^2.3.0",
|
||||
"@escape.tech/graphql-armor-max-directives": "^2.2.0",
|
||||
"@escape.tech/graphql-armor-max-tokens": "^2.4.0",
|
||||
"@fastify/websocket": "^10.0.1",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.52.1",
|
||||
"@opentelemetry/node": "^0.24.0",
|
||||
"@premid/db": "workspace:*",
|
||||
"@sentry/node": "^8.17.0",
|
||||
"arktype": "2.0.0-rc.6",
|
||||
"defu": "^6.1.4",
|
||||
"discord-api-types": "^0.37.92",
|
||||
"fastify": "^4.28.1",
|
||||
"graphql": "^16.9.0",
|
||||
"graphql-parse-resolve-info": "^4.13.0",
|
||||
"graphql-yoga": "^5.6.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"mongoose": "^8.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "5.0.2",
|
||||
"@graphql-codegen/schema-ast": "^4.1.0",
|
||||
"@graphql-codegen/typescript": "4.0.9",
|
||||
"@graphql-codegen/typescript-resolvers": "4.2.1",
|
||||
"@parcel/watcher": "^2.4.1",
|
||||
"@types/ws": "^8.5.12"
|
||||
}
|
||||
}
|
||||
110
apps/api-worker/src/classes/Socket.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { REST } from "@discordjs/rest";
|
||||
import { scope, type } from "arktype";
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import WebSocket from "ws";
|
||||
import type { FastifyRequest } from "fastify";
|
||||
import type { RawData } from "ws";
|
||||
import { redis } from "../functions/createServer.js";
|
||||
import { counter } from "../tracing.js";
|
||||
|
||||
const schema = scope({
|
||||
token: {
|
||||
"+": "delete",
|
||||
"type": "'token'",
|
||||
"token": "string.trim",
|
||||
"expires": "number.epoch",
|
||||
},
|
||||
session: {
|
||||
"+": "delete",
|
||||
"type": "'session'",
|
||||
"token": "string.trim",
|
||||
},
|
||||
validMessages: "token | session",
|
||||
}).export();
|
||||
|
||||
export class Socket {
|
||||
currentToken: typeof schema.token.infer | undefined;
|
||||
currentSession: typeof schema.session.infer | undefined;
|
||||
discord = new REST({ version: "10", authPrefix: "Bearer" });
|
||||
|
||||
constructor(
|
||||
public readonly socket: WebSocket.WebSocket,
|
||||
public readonly request: FastifyRequest,
|
||||
) {
|
||||
counter.add(1);
|
||||
socket.on("message", this.onMessage.bind(this));
|
||||
socket.on("close", () => this.onClose());
|
||||
}
|
||||
|
||||
async onMessage(message: RawData) {
|
||||
try {
|
||||
const out = schema.validMessages(JSON.parse(message.toString()));
|
||||
|
||||
if (out instanceof type.errors) {
|
||||
return this.close(1003, out.summary);
|
||||
}
|
||||
|
||||
switch (out.type) {
|
||||
case "token": {
|
||||
this.discord.setToken(out.token);
|
||||
if (!await this.isTokenValid(out)) {
|
||||
return this.close(1003, "Invalid token");
|
||||
}
|
||||
this.currentToken = out;
|
||||
break;
|
||||
}
|
||||
case "session": {
|
||||
await redis.hdel("pmd-api.sessions", out.token);
|
||||
this.currentSession = out;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error);
|
||||
this.close(1011, "Internal Error");
|
||||
}
|
||||
}
|
||||
|
||||
async onClose() {
|
||||
counter.add(-1);
|
||||
|
||||
if (!this.currentToken || !this.currentSession)
|
||||
return;
|
||||
|
||||
await redis.hset(
|
||||
"pmd-api.sessions",
|
||||
this.currentSession.token,
|
||||
JSON.stringify({
|
||||
session: this.currentSession.token,
|
||||
token: this.currentToken.token,
|
||||
lastUpdated: Date.now(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async isTokenValid(token: typeof schema.token.infer) {
|
||||
// ? Check the expiration date of the token
|
||||
if (token.expires < Date.now())
|
||||
return false;
|
||||
|
||||
// ? See if we can get the user's information
|
||||
try {
|
||||
await this.discord.get(Routes.user());
|
||||
return true;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
send(data: any) {
|
||||
this.socket.send(JSON.stringify(data));
|
||||
}
|
||||
|
||||
close(code: number = 1000, message?: string) {
|
||||
if (this.socket.readyState === WebSocket.CLOSED)
|
||||
return;
|
||||
this.socket.close(code, message);
|
||||
}
|
||||
}
|
||||
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,
|
||||
});
|
||||
30
apps/api-worker/src/functions/createRedis.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { hostname } from "node:os";
|
||||
import process from "node:process";
|
||||
|
||||
import { Redis } from "ioredis";
|
||||
|
||||
/* c8 ignore start */
|
||||
export default function createRedis(): Redis {
|
||||
const redis = new Redis({
|
||||
connectionName: `api-${hostname()}-${process.pid.toString()}`,
|
||||
lazyConnect: true,
|
||||
name: "mymaster",
|
||||
sentinels: process.env.REDIS_SENTINELS?.split(",").map(s => ({
|
||||
host: s,
|
||||
port: 26_379,
|
||||
})),
|
||||
});
|
||||
|
||||
/* c8 ignore next 3 */
|
||||
redis.on("error", (error) => {
|
||||
console.error("Redis error", error);
|
||||
});
|
||||
|
||||
/* c8 ignore next 4 */
|
||||
redis.on("connect", () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Redis connected");
|
||||
});
|
||||
|
||||
return redis;
|
||||
}
|
||||
25
apps/api-worker/src/functions/createServer.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe.concurrent("createServer", () => {
|
||||
it("should create a server", async () => {
|
||||
const createServer = await import("./createServer.js");
|
||||
const server = await createServer.default();
|
||||
expect(server).toBeDefined();
|
||||
expect(server).toHaveProperty("listen");
|
||||
});
|
||||
|
||||
it("should handle graphql requests", async () => {
|
||||
const createServer = await import("./createServer.js");
|
||||
const server = await createServer.default();
|
||||
expect(server).toBeDefined();
|
||||
expect(server).toHaveProperty("listen");
|
||||
|
||||
const response = await server.inject({
|
||||
method: "GET",
|
||||
url: "/v5/graphql",
|
||||
});
|
||||
|
||||
expect(response).toBeDefined();
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
98
apps/api-worker/src/functions/createServer.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
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 fastify from "fastify";
|
||||
|
||||
import { createSchema, createYoga } from "graphql-yoga";
|
||||
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 { presences } from "../routes/presences.js";
|
||||
import createRedis from "./createRedis.js";
|
||||
|
||||
export interface FastifyContext {
|
||||
request: FastifyRequest;
|
||||
reply: FastifyReply;
|
||||
}
|
||||
|
||||
const __dirname = new URL(".", import.meta.url).pathname;
|
||||
|
||||
export default async function createServer() {
|
||||
const app = fastify({ logger: true });
|
||||
const yoga = createYoga<FastifyContext>({
|
||||
graphqlEndpoint: "/v5/graphql",
|
||||
logging: {
|
||||
/* c8 ignore next 12 */
|
||||
debug: (...arguments_) => {
|
||||
for (const argument of arguments_) app.log.debug(argument);
|
||||
},
|
||||
error: (...arguments_) => {
|
||||
for (const argument of arguments_) app.log.error(argument);
|
||||
},
|
||||
info: (...arguments_) => {
|
||||
for (const argument of arguments_) app.log.info(argument);
|
||||
},
|
||||
warn: (...arguments_) => {
|
||||
for (const argument of arguments_) app.log.warn(argument);
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
maxAliasesPlugin(),
|
||||
maxDepthPlugin(),
|
||||
maxDirectivesPlugin(),
|
||||
maxTokensPlugin(),
|
||||
/* useSentry(), */
|
||||
],
|
||||
schema: createSchema<FastifyContext>({
|
||||
resolvers,
|
||||
typeDefs: await readFile(
|
||||
resolve(__dirname, "../generated/schema-v5.graphql"),
|
||||
"utf8",
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
app.route({
|
||||
handler: async (request, reply) => {
|
||||
const response = await yoga.handleNodeRequest(request, {
|
||||
reply,
|
||||
request,
|
||||
});
|
||||
for (const [key, value] of response.headers.entries())
|
||||
void reply.header(key, value);
|
||||
|
||||
void reply.status(response.status);
|
||||
|
||||
void reply.send(response.body);
|
||||
|
||||
return reply;
|
||||
},
|
||||
method: ["GET", "POST", "OPTIONS"],
|
||||
url: "/v5/graphql",
|
||||
});
|
||||
|
||||
app.register(fastifyWebsocket);
|
||||
|
||||
app.register(async (app) => {
|
||||
app.get("/v5/ws", { websocket: true }, (websocket, request) => {
|
||||
void new Socket(websocket, request);
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/v5/feature-flags", async (request, reply) => {
|
||||
void reply.send(featureFlags);
|
||||
});
|
||||
|
||||
app.post("/v5/session-keep-alive", sessionKeepAlive);
|
||||
app.get("/v5/presence/:service/:file", presences);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
export const redis = createRedis();
|
||||
@@ -0,0 +1,33 @@
|
||||
import { type } from "arktype";
|
||||
import { GraphQLError } from "graphql";
|
||||
import { redis } from "../../../../functions/createServer.js";
|
||||
import type { MutationResolvers } from "../../../../generated/graphql-v5.js";
|
||||
|
||||
const addScienceSchema = type({
|
||||
identifier: "string.uuid & string.lower",
|
||||
presences: "string.trim[]",
|
||||
platform: {
|
||||
arch: "string.trim",
|
||||
os: "string.trim",
|
||||
},
|
||||
});
|
||||
|
||||
const mutation: MutationResolvers["addScience"] = async (_parent, input) => {
|
||||
const out = addScienceSchema(input);
|
||||
|
||||
if (out instanceof type.errors)
|
||||
throw new GraphQLError(out.summary);
|
||||
|
||||
await redis.hset(
|
||||
"pmd-api.scienceUpdates",
|
||||
out.identifier,
|
||||
JSON.stringify(out),
|
||||
);
|
||||
|
||||
return {
|
||||
__typename: "AddScienceResult",
|
||||
...out,
|
||||
};
|
||||
};
|
||||
|
||||
export default mutation;
|
||||
@@ -0,0 +1,54 @@
|
||||
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",
|
||||
"presence?": {
|
||||
service: "string.trim",
|
||||
version: "string.semver",
|
||||
language: "string.trim",
|
||||
since: "number.epoch",
|
||||
},
|
||||
"extension": {
|
||||
"version": "string.semver",
|
||||
"language": "string.trim",
|
||||
"connected?": {
|
||||
app: "number.integer",
|
||||
discord: "boolean",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mutation: MutationResolvers["heartbeat"] = async (_parent, input, context) => {
|
||||
const out = heartbeatSchema(input);
|
||||
|
||||
if (out instanceof type.errors)
|
||||
throw new GraphQLError(out.summary);
|
||||
|
||||
//* 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",
|
||||
...out,
|
||||
};
|
||||
};
|
||||
|
||||
export default mutation;
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { MutationResolvers } from "../../../../generated/graphql-v5.js";
|
||||
import addScience from "./addScience.js";
|
||||
import heartbeat from "./heartbeat.js";
|
||||
|
||||
export const Mutation: MutationResolvers = {
|
||||
addScience,
|
||||
heartbeat,
|
||||
};
|
||||
6
apps/api-worker/src/graphql/resolvers/v5/Query/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { QueryResolvers } from "../../../../generated/graphql-v5.js";
|
||||
import presences from "./presences.js";
|
||||
|
||||
export const Query: QueryResolvers = {
|
||||
presences,
|
||||
};
|
||||
58
apps/api-worker/src/graphql/resolvers/v5/Query/presences.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Presence } from "@premid/db";
|
||||
import { parseResolveInfo } from "graphql-parse-resolve-info";
|
||||
import type { PresenceSchema } from "@premid/db/Presence.js";
|
||||
import type { FilterQuery } from "mongoose";
|
||||
|
||||
import type { QueryResolvers } from "../../../../generated/graphql-v5.js";
|
||||
|
||||
const resolver: QueryResolvers["presences"] = async (
|
||||
_parent,
|
||||
{ author, contributor, limit, query, service, start, tag },
|
||||
_context,
|
||||
info,
|
||||
) => {
|
||||
const authorFilter: FilterQuery<PresenceSchema> = author
|
||||
? { "metadata.author.name": author }
|
||||
: {};
|
||||
const contributorFilter: FilterQuery<PresenceSchema> = contributor
|
||||
? { "metadata.contributors.name": contributor }
|
||||
: {};
|
||||
const serviceFilter: FilterQuery<PresenceSchema> = service
|
||||
? Array.isArray(service)
|
||||
? { "metadata.service": { $in: service } }
|
||||
: { "metadata.service": service }
|
||||
: {};
|
||||
const queryFilter: FilterQuery<PresenceSchema> = query
|
||||
? { "metadata.service": { $options: "i", $regex: query } }
|
||||
: {};
|
||||
const tagFilter: FilterQuery<PresenceSchema> = tag
|
||||
? { "metadata.tags": tag }
|
||||
: {};
|
||||
|
||||
const presences = await Presence.find(
|
||||
{
|
||||
...authorFilter,
|
||||
...contributorFilter,
|
||||
...serviceFilter,
|
||||
...queryFilter,
|
||||
...tagFilter,
|
||||
},
|
||||
Object.assign(
|
||||
{},
|
||||
...Object.keys(parseResolveInfo(info)!.fieldsByTypeName.Presence!).map(
|
||||
fieldName => ({ [fieldName]: true }),
|
||||
),
|
||||
) as Record<string, boolean>,
|
||||
{ ...(limit ? { limit } : {}), ...(start ? { skip: start } : {}) },
|
||||
);
|
||||
|
||||
return presences.map(presence => ({
|
||||
iframeJs: presence.iframeJs,
|
||||
metadata: presence.metadata,
|
||||
presenceJs: presence.presenceJs,
|
||||
url: presence.url,
|
||||
users: 0,
|
||||
}));
|
||||
};
|
||||
|
||||
export default resolver;
|
||||
8
apps/api-worker/src/graphql/resolvers/v5/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Resolvers } from "../../../generated/graphql-v5.js";
|
||||
import { Mutation } from "./Mutation/index.js";
|
||||
import { Query } from "./Query/index.js";
|
||||
|
||||
export const resolvers: Resolvers = {
|
||||
Query,
|
||||
Mutation,
|
||||
};
|
||||
19
apps/api-worker/src/graphql/schema/v5/addScience.gql
Normal file
@@ -0,0 +1,19 @@
|
||||
type Mutation {
|
||||
addScience(identifier: String!, presences: [String!]!, platform: PlatformInput!): AddScienceResult
|
||||
}
|
||||
|
||||
input PlatformInput {
|
||||
arch: String!
|
||||
os: String!
|
||||
}
|
||||
|
||||
type AddScienceResult {
|
||||
identifier: String!
|
||||
presences: [String!]!
|
||||
platform: Platform!
|
||||
}
|
||||
|
||||
type Platform {
|
||||
arch: String!
|
||||
os: String!
|
||||
}
|
||||
21
apps/api-worker/src/graphql/schema/v5/availableLanguages.gql
Normal file
@@ -0,0 +1,21 @@
|
||||
type Query {
|
||||
"""
|
||||
Get the available languages
|
||||
"""
|
||||
availableLanguages: [Language!]!
|
||||
}
|
||||
|
||||
type Language {
|
||||
"""
|
||||
Language code
|
||||
"""
|
||||
lang: String!
|
||||
"""
|
||||
Native name of the language, eg. 'English', 'Deutsch', 'Español', etc.
|
||||
"""
|
||||
nativeName: String!
|
||||
"""
|
||||
'ltr' or 'rtl'
|
||||
"""
|
||||
direction: String!
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
type Query {
|
||||
"""
|
||||
Get the available presence languages for a specific presence
|
||||
"""
|
||||
availablePresenceLanguages(
|
||||
"""
|
||||
Presence, e.g. 'Netflix'
|
||||
"""
|
||||
presence: StringOrStringArray
|
||||
): [PresenceLanguage!]!
|
||||
}
|
||||
|
||||
type PresenceLanguage {
|
||||
"""
|
||||
Presence, e.g. 'Netflix'
|
||||
"""
|
||||
presence: String!
|
||||
"""
|
||||
The available languages for the presence
|
||||
"""
|
||||
languages: [Language!]!
|
||||
}
|
||||
49
apps/api-worker/src/graphql/schema/v5/heartbeat.gql
Normal file
@@ -0,0 +1,49 @@
|
||||
type Mutation {
|
||||
heartbeat(
|
||||
identifier: String!
|
||||
presence: HeartbeatPresenceInput
|
||||
extension: HeartbeatExtensionInput!
|
||||
): HeartbeatResult!
|
||||
}
|
||||
|
||||
input HeartbeatPresenceInput {
|
||||
service: String!
|
||||
version: String!
|
||||
language: String!
|
||||
since: Float!
|
||||
}
|
||||
|
||||
input HeartbeatExtensionInput {
|
||||
version: String!
|
||||
language: String!
|
||||
connected: HeartbeatConnectedInput
|
||||
}
|
||||
|
||||
input HeartbeatConnectedInput {
|
||||
app: Int!
|
||||
discord: Boolean!
|
||||
}
|
||||
|
||||
type HeartbeatResult {
|
||||
identifier: String!
|
||||
presence: HeartbeatPresence
|
||||
extension: HeartbeatExtension!
|
||||
}
|
||||
|
||||
type HeartbeatPresence {
|
||||
service: String!
|
||||
version: String!
|
||||
language: String!
|
||||
since: Float!
|
||||
}
|
||||
|
||||
type HeartbeatExtension {
|
||||
version: String!
|
||||
language: String!
|
||||
connected: HeartbeatConnected
|
||||
}
|
||||
|
||||
type HeartbeatConnected {
|
||||
app: Int!
|
||||
discord: Boolean!
|
||||
}
|
||||
58
apps/api-worker/src/graphql/schema/v5/presences.gql
Normal file
@@ -0,0 +1,58 @@
|
||||
type Query {
|
||||
presences(
|
||||
service: StringOrStringArray
|
||||
author: String
|
||||
contributor: String
|
||||
start: Int
|
||||
limit: Int
|
||||
query: String
|
||||
tag: String
|
||||
): [Presence!]!
|
||||
}
|
||||
|
||||
type Presence {
|
||||
url: String!
|
||||
metadata: PresenceMetadata!
|
||||
presenceJs: String!
|
||||
iframeJs: String
|
||||
users: Int!
|
||||
}
|
||||
|
||||
type PresenceMetadata {
|
||||
apiVersion: Int!
|
||||
author: PresenceMetadataUser!
|
||||
contributors: [PresenceMetadataUser!]
|
||||
altnames: [String!]
|
||||
service: String!
|
||||
description: Scalar! # serialize
|
||||
url: Scalar! # serialize
|
||||
version: String!
|
||||
logo: String!
|
||||
thumbnail: String!
|
||||
color: String!
|
||||
tags: [String!]!
|
||||
category: String!
|
||||
iframe: Boolean
|
||||
regExp: String
|
||||
iFrameRegExp: String
|
||||
readLogs: Boolean
|
||||
button: Boolean
|
||||
warning: Boolean
|
||||
settings: [PresenceMetadataSettings!]
|
||||
}
|
||||
|
||||
type PresenceMetadataUser {
|
||||
id: String!
|
||||
name: String!
|
||||
}
|
||||
|
||||
type PresenceMetadataSettings {
|
||||
id: String!
|
||||
title: String
|
||||
icon: String
|
||||
if: Scalar # serialize
|
||||
placeholder: String
|
||||
value: Scalar # serialize
|
||||
values: Scalar # serialize
|
||||
multiLanguage: Scalar # serialize
|
||||
}
|
||||
1
apps/api-worker/src/graphql/schema/v5/scalar/Scalar.gql
Normal file
@@ -0,0 +1 @@
|
||||
scalar Scalar
|
||||
@@ -0,0 +1 @@
|
||||
scalar StringOrStringArray
|
||||
27
apps/api-worker/src/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/* eslint-disable no-console */
|
||||
import process from "node:process";
|
||||
import * as Sentry from "@sentry/node";
|
||||
import { connect } from "mongoose";
|
||||
import "./tracing.js";
|
||||
import createServer from "./functions/createServer.js";
|
||||
|
||||
// TODO SETUP SENTRY
|
||||
Sentry.init({
|
||||
integrations: [
|
||||
Sentry.graphqlIntegration(),
|
||||
Sentry.mongooseIntegration(),
|
||||
],
|
||||
});
|
||||
|
||||
if (!process.env.DATABASE_URL)
|
||||
throw new Error("DATABASE_URL is not set");
|
||||
|
||||
await connect(process.env.DATABASE_URL, { appName: "PreMiD API", dbName: "PreMiD" });
|
||||
|
||||
const server = await createServer();
|
||||
const url = await server.listen({
|
||||
port: Number.parseInt(process.env.PORT ?? "3001"),
|
||||
host: process.env.HOST ?? "0.0.0.0",
|
||||
});
|
||||
|
||||
console.log(`Server listening at ${url}`);
|
||||
34
apps/api-worker/src/routes/presences.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Presence } from "@premid/db";
|
||||
import { type } from "arktype";
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
|
||||
const schema = type({
|
||||
service: "string.trim",
|
||||
file: "'metadata.json'|'presence.js'|'iframe.js'",
|
||||
});
|
||||
|
||||
export async function presences(request: FastifyRequest, reply: FastifyReply) {
|
||||
const out = schema(request.params);
|
||||
|
||||
if (out instanceof type.errors)
|
||||
return reply.status(400).send({ code: "INVALID_PARAMS", message: out.message });
|
||||
|
||||
const service = decodeURIComponent(out.service);
|
||||
const { file } = out;
|
||||
|
||||
const presence = await Presence.findOne({ "metadata.service": service });
|
||||
|
||||
if (!presence)
|
||||
return reply.status(404).send({ code: "PRESENCE_NOT_FOUND", message: "The presence was not found" });
|
||||
|
||||
switch (file) {
|
||||
case "metadata.json":
|
||||
return reply.status(200).type("application/json").send(presence.metadata);
|
||||
case "presence.js":
|
||||
return reply.status(200).type("application/javascript").send(presence.presenceJs);
|
||||
case "iframe.js":
|
||||
if (!presence.iframeJs)
|
||||
return reply.status(404).send({ code: "IFRAME_NOT_FOUND", message: "The presence does not have an iframe" });
|
||||
return reply.status(200).type("application/javascript").send(presence.iframeJs);
|
||||
}
|
||||
}
|
||||
62
apps/api-worker/src/routes/sessionKeepAlive.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import process from "node:process";
|
||||
import { REST } from "@discordjs/rest";
|
||||
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) {
|
||||
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)
|
||||
return reply.status(400).send({ code: "MISSING_HEADERS", message: out.message });
|
||||
|
||||
if (!await isTokenValid(out.token))
|
||||
return reply.status(400).send({ code: "INVALID_TOKEN", message: "The token is invalid" });
|
||||
|
||||
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"); // 5 seconds
|
||||
|
||||
return reply.status(200).send({
|
||||
code: "OK",
|
||||
message: "Session updated",
|
||||
nextUpdate: interval,
|
||||
});
|
||||
}
|
||||
|
||||
async function isTokenValid(token: string) {
|
||||
const discord = new REST({ version: "10", authPrefix: "Bearer" });
|
||||
|
||||
discord.setToken(token);
|
||||
try {
|
||||
await discord.get(Routes.user());
|
||||
return true;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
18
apps/api-worker/src/tracing.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ValueType } from "@opentelemetry/api";
|
||||
import { PrometheusExporter } from "@opentelemetry/exporter-prometheus";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
|
||||
const prometheusExporter = new PrometheusExporter();
|
||||
|
||||
const provider = new MeterProvider({
|
||||
readers: [prometheusExporter],
|
||||
});
|
||||
|
||||
const meter = provider.getMeter("nice");
|
||||
|
||||
export const counter = meter.createUpDownCounter("active_activites", {
|
||||
description: "Number of active activities",
|
||||
valueType: ValueType.INT,
|
||||
});
|
||||
|
||||
prometheusExporter.startServer();
|
||||
10
apps/api-worker/tsconfig.app.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"rootDir": "src",
|
||||
"types": ["@ark/schema"],
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
8
apps/api-worker/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["environment.d.ts", "src", "codegen.ts"]
|
||||
}
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||