43 Commits

Author SHA1 Message Date
c99c11c241 feat(status): add new game server notification for RYZEN-GAME 02 2025-11-07 10:32:17 +01:00
df048c1352 fix(status): remove unnecessary quotes from host and name properties 2025-11-04 18:13:17 +01:00
290e8b982a feat(status): enhance website status message with globe icon and separator 2025-11-03 18:32:35 +01:00
8bfeb1c43c fix(status): update status message to include website link in notifications 2025-11-03 18:27:35 +01:00
e2a8255d5a fix(docker): comment out npm run register command in Dockerfile 2025-11-03 18:17:58 +01:00
85ec27cb2b fix(docker): correct npm command to run register in Dockerfile 2025-11-03 18:09:38 +01:00
e2c896c6f1 fix(readme): update deployment workflow status to in production 2025-11-03 18:06:00 +01:00
3ff4278217 feat(deploy): add GitHub Actions workflow for deployment to VPS 2025-11-03 18:05:47 +01:00
7b80aca9e1 fix(status): update timestamp format in notifications to include full date and time 2025-11-03 14:36:24 +01:00
afd8d1f68a fix(status): integrate dayjs for formatted timestamps in notifications 2025-11-03 14:35:06 +01:00
c571e03495 fix(client): log loaded guild configurations on client ready 2025-11-03 14:24:47 +01:00
a577f99277 fix(live_status): remove ephemeral flag from error message in channel permissions 2025-11-03 13:41:09 +01:00
4b6b2c8575 fix(live_status): change reply to editReply for error handling in channel permissions 2025-11-03 13:40:39 +01:00
e375fb2631 fix(live_status): handle errors when sending messages to the channel 2025-11-03 13:38:51 +01:00
39178d1322 fix(readme): correct project title to "Protojx Manager" 2025-11-03 13:32:09 +01:00
d7b772544f fix(live_status): add default member permissions for command execution 2025-11-03 13:23:33 +01:00
27fc82d371 fix(readme): update status of filter feature to reflect production readiness 2025-11-03 13:21:34 +01:00
956536a717 fix(status): prevent duplicate notifications by tracking user IDs 2025-11-03 11:53:02 +01:00
0f22892816 fix(status): filter users by enabled status in follow repository query 2025-11-03 11:46:53 +01:00
a593e05f5c feat(follow): add host option for notifications and update follow entity 2025-11-03 11:45:42 +01:00
d417e7334b fix(readme): update status of notification system and persistent messages 2025-11-03 10:15:27 +01:00
96fcb53e0b fix(status): simplify name for XEON 01 host entry in status service 2025-11-03 10:02:35 +01:00
fc5c2b1e63 feat(status): add XEON 02 and XEON 03 host entries to status service 2025-11-03 09:59:28 +01:00
3821583d1d refactor(status): comment out XEON 02 host entry in status service 2025-11-03 09:11:28 +01:00
91e95127c1 fix(status): correct spelling of 'Protojx' in status display message 2025-11-02 16:55:31 +01:00
f05a35965e chore(docker): add WORKDIR instruction to set application directory 2025-10-31 17:36:16 +01:00
b9930cbc8f feat(live_status): add command to generate and update persistent status messages 2025-10-31 15:09:12 +01:00
d3fba3668e feat(status): enhance notification system to include status change alerts for all hosts 2025-10-31 13:31:06 +01:00
49da70082e feat(status): ensure notifications are sent only for enabled hosts 2025-10-31 12:30:20 +01:00
37fbfd1c8c feat(status): update notification message in text display for outage alerts 2025-10-31 11:04:42 +01:00
b13c77d9a5 feat(dependencies): update discord.js to version 14.24.2 2025-10-31 10:57:08 +01:00
24ed5e6a62 feat(readme): update notification system status to completed 2025-10-31 10:24:20 +01:00
d4e90640b6 feat(status): add follow notifications for status changes and integrate follow repository 2025-10-31 10:24:02 +01:00
522ed2ba81 feat(status): implement log cleanup and enhance status fetching in StatusService 2025-10-31 10:04:06 +01:00
6b10aaa009 feat: refactor command imports, enhance status service with logging and notification features, and update entity definitions 2025-10-31 10:02:44 +01:00
03769b14fa feat(status): refactor status command to utilize centralized container generation in status service 2025-10-31 09:31:35 +01:00
d964ec7963 feat(status): enhance server status command with structured response and improved host categorization 2025-10-31 08:40:11 +01:00
17c00211da feat(status): add emoji support for RYZEN and XEON hosts in status monitoring 2025-10-31 08:06:48 +01:00
4bb33bea89 Merge branch 'main' of https://github.com/thedrewen/protojx-manager-non-official 2025-10-31 07:52:47 +01:00
bbd071c44f fix(follow): correct integration type from GuildInstall to UserInstall 2025-10-31 07:52:45 +01:00
3c6fd92cc2 fix(status): increase ping timeout from 3 to 10 seconds for improved reliability 2025-10-30 21:16:43 +01:00
5f770d861d fix(fetch): adjust host filtering logic to use max_ping for improved accuracy 2025-10-30 19:55:21 +01:00
2822908ea0 feat(follow): implement follow command for service status notifications
refactor(data-source): update entity and migration paths for consistency
fix(readme): update status feature notification system icon
delete(guilds): remove unused Guild entity
add(follow): create Follow entity for managing user notification preferences
2025-10-30 08:41:21 +01:00
19 changed files with 514 additions and 97 deletions

View File

@@ -6,6 +6,9 @@ CLIENT_ID=your_discord_client_id_here
EMOJI_STATUS_ONLINE=<a:online:1432684754276323431> EMOJI_STATUS_ONLINE=<a:online:1432684754276323431>
EMOJI_STATUS_OFFLINE=<a:offline:1432684900175183882> EMOJI_STATUS_OFFLINE=<a:offline:1432684900175183882>
EMOJI_RYZEN=<:ryzen:1433711892009848833>
EMOJI_XEON=<:xeon:1433711864168054855>
# Database Configuration # Database Configuration
DB_HOST=localhost DB_HOST=localhost
DB_PORT=5432 DB_PORT=5432

52
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
# This is a basic workflow to help you get started with Actions
name: Deploy
# Controls when the workflow will run
on:
push:
tags:
- '*'
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
build:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v4
- name: Setup SSH Agent
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Add VPS to known_hosts
run: |
ssh-keyscan -p ${{ secrets.VPS_PORT }} -H ${{ secrets.VPS_HOST }} >> ~/.ssh/known_hosts
- name: Deploy to VPS
run: |
ssh -p ${{ secrets.VPS_PORT }} ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
cd /home/${{ secrets.VPS_USER }}/protojx/protojx-manager &&
git pull &&
# Stop and remove old container if exists
docker stop protojx_manager 2>/dev/null || true &&
docker rm protojx_manager 2>/dev/null || true &&
# Build new image
docker buildx build -t protojx_manager . &&
# Run new container
docker run -d \
--name protojx_manager \
--restart unless-stopped \
--network shared \
protojx_manager
"

View File

@@ -1,8 +1,12 @@
FROM node:22 FROM node:22
WORKDIR /app
COPY . . COPY . .
RUN apt-get update && apt-get install -y iputils-ping RUN apt-get update && apt-get install -y iputils-ping
RUN npm i RUN npm i
# RUN npm run register
CMD [ "npm", "run", "start" ] CMD [ "npm", "run", "start" ]

View File

@@ -1,4 +1,4 @@
# Protojx Manager Non Official # Protojx Manager
A status bot and other features for protojx. A status bot and other features for protojx.
- Add the bot : https://discord.com/oauth2/authorize?client_id=1432680068085190656 - Add the bot : https://discord.com/oauth2/authorize?client_id=1432680068085190656
@@ -10,9 +10,10 @@ A status bot and other features for protojx.
|-------------|--------| |-------------|--------|
| /status command | 🌐 | | /status command | 🌐 |
| Number of services down in the bot's status. | 🌐 | | Number of services down in the bot's status. | 🌐 |
| Notification system in case of downtime. | | | Notification system in case of downtime. | 🌐 |
| Ability to create persistent status messages that update automatically. | | | Ability to create persistent status messages that update automatically. (/live_status) | 🌐 |
| Deployment workflow on Raspberry Pi. | | | Deployment workflow on Oracle VPS. | 🌐 |
| Filter for notifs. | 🌐 |
- 🌐 -> In production - 🌐 -> In production
- ✅ -> Done - ✅ -> Done

19
package-lock.json generated
View File

@@ -11,7 +11,8 @@
"dependencies": { "dependencies": {
"@types/ping": "^0.4.4", "@types/ping": "^0.4.4",
"cron": "^4.3.3", "cron": "^4.3.3",
"discord.js": "^14.24.1", "dayjs": "^1.11.19",
"discord.js": "^14.24.2",
"dotenv": "^17.2.2", "dotenv": "^17.2.2",
"pg": "^8.16.3", "pg": "^8.16.3",
"ping": "^1.0.0", "ping": "^1.0.0",
@@ -552,9 +553,9 @@
} }
}, },
"node_modules/dayjs": { "node_modules/dayjs": {
"version": "1.11.18", "version": "1.11.19",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
"integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/debug": { "node_modules/debug": {
@@ -615,9 +616,9 @@
] ]
}, },
"node_modules/discord.js": { "node_modules/discord.js": {
"version": "14.24.1", "version": "14.24.2",
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.24.1.tgz", "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.24.2.tgz",
"integrity": "sha512-LzL+MTGxB9mBwD8FjvkMwcIL4UtgG04e713U3+euqPCvOphhoVEoPpUNTvBPw4iJOas2uiuuh3JcveYSxIn8Tg==", "integrity": "sha512-VMEDbmguRdX/EeMaTsf9Mb0IQA90WdYF2cn4QDfslQFXgQ6LFtmlPn0FSotnS0kcFbFp+JBSIxtnF+bnAHG/hQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@discordjs/builders": "^1.13.0", "@discordjs/builders": "^1.13.0",
@@ -1090,7 +1091,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"pg-connection-string": "^2.9.1", "pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1", "pg-pool": "^3.10.1",
@@ -1236,8 +1236,7 @@
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0", "license": "Apache-2.0"
"peer": true
}, },
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",

View File

@@ -30,7 +30,8 @@
"dependencies": { "dependencies": {
"@types/ping": "^0.4.4", "@types/ping": "^0.4.4",
"cron": "^4.3.3", "cron": "^4.3.3",
"discord.js": "^14.24.1", "dayjs": "^1.11.19",
"discord.js": "^14.24.2",
"dotenv": "^17.2.2", "dotenv": "^17.2.2",
"pg": "^8.16.3", "pg": "^8.16.3",
"ping": "^1.0.0", "ping": "^1.0.0",

View File

@@ -0,0 +1,55 @@
import { ApplicationIntegrationType, ChatInputCommandInteraction, InteractionContextType, MessageFlags, SlashCommandBuilder } from "discord.js";
import { CommandDefinition } from "../../type";
import { AppDataSource } from "../../data-source";
import { Follow } from "../../entity/follow.entity";
import statusService from "../../services/status.service";
const cmd : CommandDefinition = {
data: new SlashCommandBuilder()
.setName('follow')
.setDescription('Enables/disables the receipt of service status notifications.')
.setIntegrationTypes(
ApplicationIntegrationType.UserInstall
)
.setContexts(
InteractionContextType.BotDM,
InteractionContextType.Guild,
InteractionContextType.PrivateChannel
)
.addStringOption((option) =>
option
.setRequired(true)
.addChoices(...statusService.hosts.filter((v) => v.notify).map((s) => ({name: s.name, value: s.host})))
.setName('host')
.setDescription('Host enable/disable.')
),
async execute(interaction : ChatInputCommandInteraction) {
const userRepo = AppDataSource.getRepository(Follow);
const hostvalue = interaction.options.getString('host');
const realHost = statusService.hosts.filter((v) => v.host == hostvalue);
if(!hostvalue || realHost.length == 0) {
await interaction.reply({content: '⚠️ Host not found !', flags: [MessageFlags.Ephemeral]});
}else{
let follow = await userRepo.findOne({where: {user_discord: interaction.user.id, host: hostvalue}});
if(!follow) {
follow = new Follow();
follow.user_discord = interaction.user.id;
follow.host = hostvalue;
await userRepo.save(follow);
}
follow.enable = !follow.enable;
await userRepo.save(follow);
await interaction.reply({content: `✅ Notification successfully ${follow.enable ? 'enabled 🔔' : 'disabled 🔕'} for ${realHost[0]?.name}!`, flags: [MessageFlags.Ephemeral]});
if(follow.enable) {
await interaction.user.send({content: `🔔 Notifications have been successfully enabled for ${realHost[0]?.name} ! To disable: /follow host:${realHost[0]?.name}`})
}
}
}
}
export default cmd;

View File

@@ -0,0 +1,82 @@
import { ApplicationIntegrationType, ChannelType, ContainerBuilder, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from "discord.js";
import { CommandDefinition } from "../../type";
import statusService from "../../services/status.service";
import { AppDataSource } from "../../data-source";
import { Guild } from "../../entity/guild.entity";
const cmd : CommandDefinition = {
data: new SlashCommandBuilder()
.setName('live_status')
.setDescription('Generate a permanent status message that updates every 2 minutes.')
.addChannelOption((option) => option
.setName('channel')
.setDescription('The message will be generated')
.addChannelTypes(ChannelType.GuildText)
.setRequired(true)
)
.setIntegrationTypes(
ApplicationIntegrationType.GuildInstall
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
async execute(interaction) {
await interaction.deferReply({flags: [MessageFlags.Ephemeral]});
const channel_options = await interaction.options.getChannel("channel");
if(channel_options && interaction.guildId){
const channel = await interaction.guild?.channels.fetch(channel_options?.id);
if(channel?.isSendable()) {
let message;
try {
message = await channel.send({components: [statusService.getUpdatedContainer(true)], flags: [MessageFlags.IsComponentsV2]});
} catch (error) {
await interaction.editReply({content: 'An error has occurred. Please check the permissions for the channel.'});
return;
}
try {
const guildRepo = AppDataSource.getRepository(Guild);
let guild = await guildRepo.findOne({where: {guild_id: interaction.guildId}});
if(guild) {
const messageId = guild.persistent_message_id;
const channelId = guild.persistent_message_channel_id;
try {
const beforeChannel = await interaction.guild?.channels.fetch(channelId);
if(beforeChannel && beforeChannel.isSendable()) {
try {
const beforeMessage = await beforeChannel.messages.fetch(messageId);
const container = new ContainerBuilder()
.addTextDisplayComponents((t) => t.setContent('This message is no longer valid!'));
beforeMessage.edit({components: [container]});
} catch (error) {}
}
} catch (error) {
}
}else{
guild = new Guild();
guild.guild_id = interaction.guildId;
}
guild.persistent_message_channel_id = channel.id;
guild.persistent_message_id = message.id;
await guildRepo.save(guild);
await interaction.editReply('Message successfully generated!')
} catch (error) {
interaction.editReply('An error has occured ! '+error);
}
}else{
interaction.editReply('The selected channel is invalid!');
}
}else{
interaction.editReply('The selected channel is invalid!');
}
},
}
export default cmd;

View File

@@ -1,4 +1,4 @@
import { ApplicationIntegrationType, ButtonInteraction, ButtonStyle, ChatInputCommandInteraction, CommandInteraction, ComponentType, ContainerBuilder, InteractionContextType, MessageFlags, SlashCommandBuilder } from "discord.js"; import { ApplicationIntegrationType, ButtonInteraction, ButtonStyle, ChatInputCommandInteraction, ContainerBuilder, InteractionContextType, MessageFlags, SlashCommandBuilder } from "discord.js";
import { CommandDefinition } from "../../type"; import { CommandDefinition } from "../../type";
const cmd : CommandDefinition = { const cmd : CommandDefinition = {

View File

@@ -1,7 +1,6 @@
import { ApplicationIntegrationType, ChatInputCommandInteraction, CommandInteraction, EmbedBuilder, InteractionContextType, SlashCommandBuilder } from "discord.js"; import { ApplicationIntegrationType, ChatInputCommandInteraction, InteractionContextType, MessageFlags, SlashCommandBuilder } from "discord.js";
import ping from "ping";
import statusService from "../../services/status.service"; import statusService from "../../services/status.service";
import { CommandDefinition } from "../../type"; import { CommandDefinition} from "../../type";
const cmd : CommandDefinition = { const cmd : CommandDefinition = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -18,18 +17,7 @@ const cmd : CommandDefinition = {
), ),
async execute(interaction : ChatInputCommandInteraction) { async execute(interaction : ChatInputCommandInteraction) {
await interaction.deferReply(); await interaction.deferReply();
await interaction.editReply({components: [statusService.getUpdatedContainer()], flags: MessageFlags.IsComponentsV2});
const embed = new EmbedBuilder();
embed.setTitle('Status of protojx servers');
embed.setColor(0xffffff);
embed.setTimestamp(new Date());
embed.setThumbnail(interaction.client.user.avatarURL())
for(let host of statusService.hosts){
embed.addFields({name: host.name, value: host.alive ? `${process.env.EMOJI_STATUS_ONLINE} Online` : `${process.env.EMOJI_STATUS_OFFLINE} Offline`, inline: false});
}
await interaction.editReply({embeds: [embed]});
} }
} }

View File

@@ -11,9 +11,9 @@ export const AppDataSource = new DataSource({
username: process.env.DB_USERNAME || "postgres", username: process.env.DB_USERNAME || "postgres",
password: process.env.DB_PASSWORD, password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE, database: process.env.DB_DATABASE,
synchronize: process.env.NODE_ENV !== "production", // false en production synchronize: process.env.NODE_ENV !== "production",
logging: process.env.DB_LOGGING === "true", logging: process.env.DB_LOGGING === "true",
entities: ["./entity/**/*.js"], entities: [__dirname + '/**/*.entity.js'],
migrations: ["./migration/**/*.js"], migrations: [__dirname + "/**/*.migration.js"],
subscribers: ["./subscriber/**/*.js"], subscribers: [__dirname + "/**/*.subscriber.js"],
}) })

View File

@@ -21,7 +21,7 @@ for (const folder of commandFolders) {
if (!fs.statSync(commandsPath).isDirectory()) continue; if (!fs.statSync(commandsPath).isDirectory()) continue;
const commandFiles = fs.readdirSync(commandsPath).filter(file => const commandFiles = fs.readdirSync(commandsPath).filter(file =>
file.endsWith('.js') file.endsWith('.command.js')
); );
for (const file of commandFiles) { for (const file of commandFiles) {

View File

@@ -0,0 +1,16 @@
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity({name: 'follows'})
export class Follow {
@PrimaryGeneratedColumn()
id: number;
@Column()
user_discord: string;
@Column()
host: string;
@Column({default: false})
enable: boolean;
}

View File

@@ -1,6 +1,6 @@
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity() @Entity({name: 'guilds'})
export class Guild { export class Guild {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id: number; id: number;
@@ -10,4 +10,7 @@ export class Guild {
@Column() @Column()
persistent_message_id: string; persistent_message_id: string;
@Column()
persistent_message_channel_id: string;
} }

View File

@@ -0,0 +1,16 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity({name: 'hosts_logs'})
export class HostsLog {
@PrimaryGeneratedColumn()
id: number;
@Column()
host: string;
@Column()
status: boolean;
@CreateDateColumn()
created_at: Date;
}

View File

@@ -1,4 +1,4 @@
import { ButtonInteraction, ChatInputCommandInteraction, Client, Collection, Events, GatewayIntentBits, MessageFlags, SlashCommandBuilder } from "discord.js"; import { Client, Collection, Events, GatewayIntentBits, MessageFlags } from "discord.js";
import { configDotenv } from "dotenv"; import { configDotenv } from "dotenv";
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
@@ -86,6 +86,9 @@ client.on(Events.InteractionCreate, async interaction => {
client.once(Events.ClientReady, readyClient => { client.once(Events.ClientReady, readyClient => {
console.log(`Ready! Logged in as ${readyClient.user.tag}`); console.log(`Ready! Logged in as ${readyClient.user.tag}`);
client.guilds.cache.forEach((value) => {
console.log(`${value.name} conf loaded !`);
});
statusService.setClient(client); statusService.setClient(client);
}); });

View File

@@ -1,79 +1,165 @@
import ping from "ping"; import ping from "ping";
import * as cron from 'cron'; import * as cron from 'cron';
import { ActivityType, Client } from "discord.js"; import { ActivityType, Client, ContainerBuilder, MessageFlags } from "discord.js";
import { Host } from "../type"; import { Host, InfraType } from "../type";
import { AppDataSource } from "../data-source";
import { HostsLog } from "../entity/hostslog.entity";
import { Repository } from "typeorm";
import { Follow } from "../entity/follow.entity";
import { Guild } from "../entity/guild.entity";
import dayjs from "dayjs";
type Nofity = {time: Date, name : string, alive : boolean, type : InfraType, host: string};
export class StatusService { export class StatusService {
public hosts : Host[] = [ public hosts: Host[] = [
{ {
'host': 'https://protojx.com', host: 'https://protojx.com',
'name': 'Protojx Website 🌐', name: 'Protojx Website',
alive: false, alive: false,
type: 'website' ping_type: 'website',
type: 'website',
notify: false
}, },
{ {
'host': 'https://manager.protojx.com', host: 'https://manager.protojx.com',
'name': 'Espace Client 💻', name: 'Espace Client',
alive: false, alive: false,
type: 'website' ping_type: 'website',
type: 'website',
notify: false
}, },
{ {
host: '5.178.99.4', host: '5.178.99.4',
name: 'RYZEN 01 🖥️', name: 'RYZEN 01',
alive: false, alive: false,
type: 'ping' ping_type: 'ping',
type: 'ryzen',
notify: true
}, },
{ {
host: '5.178.99.6', host: '5.178.99.6',
name: 'RYZEN 02 🖥️', name: 'RYZEN 02',
alive: false, alive: false,
type: 'ping' ping_type: 'ping',
type: 'ryzen',
notify: true
}, },
{ {
host: '5.178.99.5', host: '5.178.99.5',
name: 'RYZEN 03 🖥️', name: 'RYZEN 03',
alive: false, alive: false,
type: 'ping' ping_type: 'ping',
type: 'ryzen',
notify: true
},
{
host: '154.16.254.10',
name: 'RYZEN7 04',
alive: false,
ping_type: 'ping',
type: 'ryzen',
notify: true
}, },
{ {
host: '5.178.99.177', host: '5.178.99.177',
name: 'XEON 01 (2697A V4) 🖥️', name: 'XEON 01',
alive: false, alive: false,
type: 'ping' ping_type: 'ping',
type: 'xeon',
notify: true
}, },
{ {
host: '5.178.99.248', host: '154.16.254.45',
name: 'XEON 02 (2687W V4) 🖥️', name: 'XEON 02',
alive: false, alive: false,
type: 'ping' ping_type: 'ping',
type: 'xeon',
notify: true
},
{
host: '5.178.99.232',
name: 'XEON 03',
alive: false,
ping_type: 'ping',
type: 'xeon',
notify: true
}, },
{ {
host: '5.178.99.53', host: '5.178.99.53',
name: 'RYZEN-GAME 01 👾', name: 'RYZEN-GAME 01',
alive: false, alive: false,
type: 'ping' ping_type: 'ping',
type: 'games',
notify: true
},
{
host: '5.178.99.17',
name: 'RYZEN-GAME 02',
alive: false,
ping_type: 'ping',
type: 'games',
notify: true
}, },
{ {
host: '5.178.99.63', host: '5.178.99.63',
name: 'XEON-GAME 01 👾', name: 'XEON-GAME 01',
alive: false, alive: false,
type: 'ping' ping_type: 'ping',
type: 'games',
notify: true
} }
] ];
private client : Client|null = null; private client: Client | null = null;
private hostsLogRepo: Repository<HostsLog>;
private followRepo: Repository<Follow>;
private guildRepo: Repository<Guild>;
constructor() { constructor() {
this.hostsLogRepo = AppDataSource.getRepository(HostsLog);
this.followRepo = AppDataSource.getRepository(Follow);
this.guildRepo = AppDataSource.getRepository(Guild);
setTimeout(async () => { setTimeout(async () => {
await this.fetch() await this.fetch()
this.updateClientStatus(); this.updateClientStatus();
}, 3000); }, 3000);
const cronJob = new cron.CronJob('*/2 * * * *', async () => { const cronJob = new cron.CronJob('*/2 * * * *', async () => {
// ? cleanup logs
const oneMonthAgo = new Date();
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
this.hostsLogRepo.
createQueryBuilder()
.delete()
.from(HostsLog)
.where("created_at < :date", { date: oneMonthAgo })
.execute();
// ? Get status
try { try {
await this.fetch(); await this.fetch();
await this.updateClientStatus(); await this.updateClientStatus();
// ? Message Live
const guilds = await this.guildRepo.find();
guilds.forEach(async (gdb) => {
if(this.client) {
try {
const guild = await this.client.guilds.fetch(gdb.guild_id);
const channel = await guild.channels.fetch(gdb.persistent_message_channel_id);
if(channel?.isSendable()) {
const message = await channel.messages.fetch(gdb.persistent_message_id);
await message.edit({components: [this.getUpdatedContainer(true)]});
}
} catch (error) {}
}
});
console.log('Status check completed at:', new Date().toISOString()); console.log('Status check completed at:', new Date().toISOString());
} catch (error) { } catch (error) {
console.error('Error during status check:', error); console.error('Error during status check:', error);
@@ -83,30 +169,33 @@ export class StatusService {
console.log('Status monitoring started - checking every 2 minutes'); console.log('Status monitoring started - checking every 2 minutes');
} }
setClient(client : Client) { setClient(client: Client) {
this.client = client; this.client = client;
this.client.user?.setActivity({name: '💭 Server load and status...'}) this.client.user?.setActivity({ name: '💭 Server load and status...' })
} }
private async updateClientStatus() { private async updateClientStatus() {
if(this.client) { if (this.client) {
const hosts = this.hosts.length; const hosts = this.hosts.length;
const hostsAlive = this.hosts.filter((h) => h.alive).length; const hostsAlive = this.hosts.filter((h) => h.alive).length;
this.client.user?.setActivity({name: ( this.client.user?.setActivity({
name: (
hosts == hostsAlive ? '✅ All services are online.' : `📛 ${hosts - hostsAlive} service${hosts - hostsAlive > 1 ? 's' : ''} offline.` hosts == hostsAlive ? '✅ All services are online.' : `📛 ${hosts - hostsAlive} service${hosts - hostsAlive > 1 ? 's' : ''} offline.`
), type: ActivityType.Watching}); ), type: ActivityType.Watching
});
} }
} }
private async fetch(max = 1): Promise<Host[]> { private async fetchAlive(host: Host, notifs : Nofity[]) {
const hosts = this.hosts.filter((value, index) => index < max * 10 && index >= (max - 1) * 10); const latestLog = await this.hostsLogRepo.findOne({ where: { host: host.host }, order: { created_at: 'DESC' } });
async function fetchAlive(host: Host) {
if(host.type === 'ping'){ // ? Ping and Request Hosts
let res = await ping.promise.probe(host.host, {timeout: 3}); if (host.ping_type === 'ping') {
let res = await ping.promise.probe(host.host, { timeout: 10 });
host.alive = res.alive; host.alive = res.alive;
}else if(host.type === 'website'){ } else if (host.ping_type === 'website') {
try { try {
const response = await fetch(host.host, { method: 'HEAD', signal: AbortSignal.timeout(10000) }); const response = await fetch(host.host, { method: 'HEAD', signal: AbortSignal.timeout(10000) });
host.alive = response.ok; host.alive = response.ok;
@@ -114,24 +203,120 @@ export class StatusService {
host.alive = false; host.alive = false;
} }
} }
// ? Notification System :
if (!latestLog || latestLog.status != host.alive) {
const log = new HostsLog();
log.host = host.host;
log.status = host.alive;
this.hostsLogRepo.save(log);
if(latestLog && host.notify) {
notifs.push({alive: host.alive, name: host.name, time: new Date(), type: host.type, host: host.host});
}
}
return host; return host;
} }
const fetchPromises = hosts.map(host => fetchAlive(host)); private async fetch(max = 1, notifs : Nofity[] = []) {
const max_ping = 3;
const hosts = this.hosts.filter((value, index) => index < max * max_ping && index >= (max - 1) * max_ping);
const fetchPromises = hosts.map(host => this.fetchAlive(host, notifs));
const updatedHosts = await Promise.all(fetchPromises); const updatedHosts = await Promise.all(fetchPromises);
updatedHosts.forEach((updatedHost, index) => { updatedHosts.forEach((updatedHost, index) => {
const originalIndex = (max - 1) * 10 + index; const originalIndex = (max - 1) * max_ping + index;
if (originalIndex < this.hosts.length) { if (originalIndex < this.hosts.length) {
this.hosts[originalIndex] = updatedHost; this.hosts[originalIndex] = updatedHost;
} }
}); });
if(this.hosts.length > max * 10) { if (this.hosts.length > max * max_ping) {
await this.fetch(max + 1); await this.fetch(max + 1, notifs);
}else if(notifs.length > 0){
// ? Notification System (part 2 !):
const container = new ContainerBuilder()
.addTextDisplayComponents((t) => t.setContent(`### 🔔 Status change alert`));
notifs.map(async (n) => {
container.addSeparatorComponents((s)=>s);
container.addTextDisplayComponents((text) => text.setContent(`${n.alive ? process.env.EMOJI_STATUS_ONLINE : process.env.EMOJI_STATUS_OFFLINE} **${n.name}** is now **${n.alive ? 'online' : 'offline'}**\n🏷 Type : ${n.type}\n🕒 Time : <t:${Math.round(new Date().getTime()/1000)}:R>`));
});
const users = await this.followRepo.find({where: {enable: true}});
const hosts = notifs.map((n) => n.host);
const users_ids : string[] = [];
users.filter(v => hosts.includes(v.host)).forEach(async (user) => {
if(!users_ids.includes(user.user_discord)) {
users_ids.push(user.user_discord)
try {
const userdc = await this.client?.users.fetch(user.user_discord);
if(userdc) {
userdc.send({components: [container], flags: [MessageFlags.IsComponentsV2]})
}
} catch (error) {}
}
});
}
} }
return this.hosts; public getUpdatedContainer(live : boolean = false): ContainerBuilder {
const hostTexts = this.hosts.map((s) => {
return { type: s.type, value: `- ${s.name} : ${s.alive ? `${process.env.EMOJI_STATUS_ONLINE} Online` : `${process.env.EMOJI_STATUS_OFFLINE} Offline`}` };
});
const container = new ContainerBuilder()
.setAccentColor(0x0000ed)
.addTextDisplayComponents((text) => text.setContent('# Status of Protojx services'+(live ? ' (live)' : '')));
const sections: { title: string, type: InfraType, thumbnail: string }[] = [
{
title: 'Websites',
type: 'website',
thumbnail: 'https://protojx.com/assets/img/home2/agent.png'
},
{
title: 'Ryzens',
type: 'ryzen',
thumbnail: 'https://iconape.com/wp-content/png_logo_vector/ryzen.png'
},
{
title: 'Xeons',
type: 'xeon',
thumbnail: 'https://upload.wikimedia.org/wikipedia/commons/3/31/Intel-Xeon-Badge-2024.jpg'
},
{
title: 'Games',
type: 'games',
thumbnail: 'https://protojx.com/assets/img/hero-img.png'
}
]
sections.map((sectionData) => {
container.addSeparatorComponents((s) => s)
container.addSectionComponents(
(section) =>
section.addTextDisplayComponents(
(text) =>
text.setContent('## ' + sectionData.title + '\n' + hostTexts.filter((v) => v.type == sectionData.type).map((v) => v.value).join('\n'))
)
.setThumbnailAccessory(
(acc) =>
acc.setURL(sectionData.thumbnail)
)
)
});
container.addSeparatorComponents((s) => s);
container.addTextDisplayComponents((text) => text.setContent(`:globe_with_meridians: Website Status : https://statut.protojx.com/\n${live ? 'Last update : ' : ''}<t:${dayjs().unix()}:f> - Receive automatic notifications when there is an outage with /follow !`));
return container;
} }
} }

12
src/type.d.ts vendored
View File

@@ -1,4 +1,12 @@
import { ButtonInteraction, ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; import { ButtonInteraction, ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
export type Host = { host: string, name: string, alive: boolean, type: 'ping' | 'website' }; export type InfraType = 'website' | 'ryzen' | 'xeon' | 'games';
export type CommandDefinition = { data: SlashCommandBuilder, execute: (interaction: ChatInputCommandInteraction) => void, buttons?: { id: string, handle: (interaction: ButtonInteraction) => void}[]}; export type Host = {
host: string,
name: string,
alive: boolean,
ping_type: 'ping' | 'website',
type: InfraType,
notify: boolean;
};
export type CommandDefinition = { data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder, execute: (interaction: ChatInputCommandInteraction) => void, buttons?: { id: string, handle: (interaction: ButtonInteraction) => void}[]};

View File

@@ -25,7 +25,8 @@
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"removeComments": false, "removeComments": false,
"preserveConstEnums": true, "preserveConstEnums": true,
"strictPropertyInitialization": false "strictPropertyInitialization": false,
"noUnusedLocals": true
}, },
"include": [ "include": [
"src/**/*" "src/**/*"