68 Commits

Author SHA1 Message Date
ecb12f12b7 fix(status): remove unnecessary console log for guild name in StatusService 2025-11-26 11:01:06 +01:00
55e4cf3e6c feat(database): refactor entities and relationships for service management 2025-11-26 10:57:57 +01:00
104023162a feat(service): add Service entity with properties for service management 2025-11-26 09:45:16 +01:00
58403fd32e fix(deploy): remove redundant line for building new Docker image in deployment script 2025-11-26 09:25:34 +01:00
fcef934e60 fix(status): update host addresses and remove unused attachment logic in status command 2025-11-26 09:22:36 +01:00
aebc6be99e fix(status): adjust canvas dimensions and scaling for improved uptime bar rendering 2025-11-25 10:49:20 +01:00
9c87e8c35e fix(status): conditionally display status bar components based on live status 2025-11-25 10:45:18 +01:00
fe8d3dc78f fix(status): log errors in message editing to improve debugging 2025-11-25 10:43:00 +01:00
401ae56113 feat(status): enhance status command with image attachment and update query logic 2025-11-25 10:37:00 +01:00
e41be1e1f0 fix(status): remove unused fs imports from status.service.ts 2025-11-25 10:23:50 +01:00
c658881d24 feat: add canvas dependency and implement uptime bar image generation
- Added 'canvas' dependency to package.json and package-lock.json.
- Updated getUpdatedContainer method in StatusService to be asynchronous.
- Implemented getStatusImageBar method in StatusService to generate a visual representation of uptime data.
- Modified live_status.command.ts and statut.command.ts to await the updated container.
- Created test.ts to demonstrate uptime bar generation and save it as an image.
- Updated tsconfig.json to target ES2024 and include relevant libraries.
2025-11-25 10:22:41 +01:00
5ec7187afd fix(status): remove unused router configurations for ROUTER-DE 03 and ROUTER-DE 04 2025-11-22 10:25:27 +01:00
2aa5c56ca7 fix(status): update router title to include demonstration data notice 2025-11-09 08:32:06 +01:00
7b35fcf31b fix(status): update host configuration for ROUTER-DE 04 2025-11-07 11:01:57 +01:00
a5476b26fe feat(status): add router configurations and update InfraType to include routers 2025-11-07 10:49:56 +01:00
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
0449654edd fix(status): increase timeout for fetch request to 10 seconds
feat(types): add missing import for ButtonInteraction in type definitions
2025-10-29 22:12:01 +01:00
CL TheDreWen
e9565a9552 Update README.md 2025-10-29 17:31:56 +01:00
0504e8262d fix(command): correct export statement in ping command and update import in status command 2025-10-29 16:27:18 +01:00
978d21a120 feat(status): import Host type for improved type safety 2025-10-29 16:26:22 +01:00
9cadfb2734 feat(command): refactor ping and status commands to use CommandDefinition type 2025-10-29 15:47:33 +01:00
5b3167bf14 feat(types): define CommandDefinition type for command handling 2025-10-29 15:42:11 +01:00
7f9468bc99 feat(command): enhance ping command with button interaction and improved response format 2025-10-29 15:36:45 +01:00
86a7429711 feat(entity): add Guild entity with primary key and columns for guild_id and persistent_message_id 2025-10-29 11:45:37 +01:00
43b7bdd8b4 feat(database): add TypeORM configuration and initialize data source
- Added TypeORM and PostgreSQL dependencies to package.json.
- Created data-source.ts to configure the database connection.
- Initialized the data source in index.ts and added logging for successful connection.
- Updated devDependencies to include @types/node for better type support.
2025-10-29 10:44:12 +01:00
58ecaf3c4c Add description for persistent status messages in README 2025-10-29 10:19:23 +01:00
21 changed files with 2582 additions and 148 deletions

View File

@@ -1,5 +1,21 @@
TOKEN=
CLIENT_ID=
# Discord Bot Configuration
TOKEN=your_discord_bot_token_here
CLIENT_ID=your_discord_client_id_here
# Custom Emojis
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
DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=postgres
DB_PASSWORD=your_database_password_here
DB_DATABASE=protojx_manager
DB_LOGGING=false
# Environment
NODE_ENV=development

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 &&
# Build new image
docker buildx build -t protojx_manager . &&
# Stop and remove old container if exists
docker stop protojx_manager 2>/dev/null || true &&
docker rm protojx_manager 2>/dev/null || true &&
# Run new container
docker run -d \
--name protojx_manager \
--restart unless-stopped \
--network shared \
protojx_manager
"

1
.gitignore vendored
View File

@@ -137,3 +137,4 @@ dist
# Vite logs files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
note.txt

View File

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

View File

@@ -1,4 +1,4 @@
# Protojx Manager Non Official
# Protojx Manager
A status bot and other features for protojx.
- Add the bot : https://discord.com/oauth2/authorize?client_id=1432680068085190656
@@ -9,9 +9,11 @@ A status bot and other features for protojx.
| Description | Status |
|-------------|--------|
| /status command | 🌐 |
| Number of services down in the bot's status. | |
| Notification system in case of downtime. | |
| Deployment workflow on Raspberry Pi. | |
| Number of services down in the bot's status. | 🌐 |
| Notification system in case of downtime. | 🌐 |
| Ability to create persistent status messages that update automatically. (/live_status) | 🌐 |
| Deployment workflow on Oracle VPS. | 🌐 |
| Filter for notifs. | 🌐 |
- 🌐 -> In production
- ✅ -> Done
@@ -92,4 +94,4 @@ npm run start
```
---
**Note**: This template is based on community best practices and has been customized for Discord bot development.
**Note**: This template is based on community best practices and has been customized for Discord bot development.

1899
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,13 +24,19 @@
},
"homepage": "https://github.com/Under-scape/discordbot-ts-template#readme",
"devDependencies": {
"@types/node": "^24.9.2",
"typescript": "^5.9.2"
},
"dependencies": {
"@types/ping": "^0.4.4",
"canvas": "^3.2.0",
"cron": "^4.3.3",
"discord.js": "^14.24.1",
"dayjs": "^1.11.19",
"discord.js": "^14.24.2",
"dotenv": "^17.2.2",
"ping": "^1.0.0"
"pg": "^8.16.3",
"ping": "^1.0.0",
"reflect-metadata": "^0.2.2",
"typeorm": "^0.3.27"
}
}

View File

@@ -0,0 +1,50 @@
// 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
// ),
// async execute(interaction: ChatInputCommandInteraction) {
// const userRepo = AppDataSource.getRepository(Follow);
// const hostvalue = interaction.options.getString('host');
// const services = await statusService.serviceRepo.find();
// const realHost = services.find((v) => v.notify && v.host == hostvalue);
// if (!hostvalue || !realHost) {
// await interaction.reply({ content: '⚠️ Host not found !', flags: [MessageFlags.Ephemeral] });
// } else {
// let follow = await userRepo.findOne({ where: { user_discord: interaction.user.id, service: { id: realHost.id } } });
// if (!follow) {
// follow = new Follow();
// follow.user_discord = interaction.user.id;
// follow.service = realHost;
// await userRepo.save(follow);
// }
// follow.enable = !follow.enable;
// await userRepo.save(follow);
// await interaction.reply({ content: `✅ Notification successfully ${follow.enable ? 'enabled 🔔' : 'disabled 🔕'} for ${realHost.name}!`, flags: [MessageFlags.Ephemeral] });
// if (follow.enable) {
// await interaction.user.send({ content: `🔔 Notifications have been successfully enabled for ${realHost.name} ! To disable: /follow host:${realHost.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: [await 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,6 +1,7 @@
import { ApplicationIntegrationType, ChatInputCommandInteraction, CommandInteraction, InteractionContextType, SlashCommandBuilder } from "discord.js";
import { ApplicationIntegrationType, ButtonInteraction, ButtonStyle, ChatInputCommandInteraction, ContainerBuilder, InteractionContextType, MessageFlags, SlashCommandBuilder } from "discord.js";
import { CommandDefinition } from "../../type";
export default {
const cmd : CommandDefinition = {
data: new SlashCommandBuilder()
.setName('ping')
.setDescription('Pong again!')
@@ -14,6 +15,35 @@ export default {
InteractionContextType.PrivateChannel
),
async execute(interaction : ChatInputCommandInteraction) {
await interaction.reply(`🏓 Latency is ${Date.now() - interaction.createdTimestamp}ms. API Latency : ${interaction.client.ws.ping}ms`);
}
}
const container = new ContainerBuilder()
.addTextDisplayComponents((textDisplay) => textDisplay.setContent(`🏓 Latency is ${Date.now() - interaction.createdTimestamp}ms. API Latency : ${interaction.client.ws.ping}ms`))
.addSeparatorComponents((s) => s)
.addSectionComponents((section) =>
section
.addTextDisplayComponents((textDisplay) =>
textDisplay
.setContent('Oh, that\'s a beautiful button!')
)
.setButtonAccessory((button) =>
button
.setCustomId('testClick')
.setLabel('Click Me !')
.setStyle(ButtonStyle.Success)
)
)
// await interaction.reply();
await interaction.reply({
components: [container],
flags: MessageFlags.IsComponentsV2
})
},
buttons: [
{id: 'testClick', handle: (interaction : ButtonInteraction) => {
interaction.reply({content: 'Ho !', flags: [MessageFlags.Ephemeral]})
}}
]
}
export default cmd;

View File

@@ -1,8 +1,8 @@
import { ApplicationIntegrationType, ChatInputCommandInteraction, CommandInteraction, EmbedBuilder, InteractionContextType, SlashCommandBuilder } from "discord.js";
import ping from "ping";
import { ApplicationIntegrationType, ChatInputCommandInteraction, InteractionContextType, MessageFlags, SlashCommandBuilder } from "discord.js";
import statusService from "../../services/status.service";
import { CommandDefinition} from "../../type";
export default {
const cmd : CommandDefinition = {
data: new SlashCommandBuilder()
.setName('status')
.setDescription('Give statut of servers.')
@@ -17,17 +17,8 @@ export default {
),
async execute(interaction : ChatInputCommandInteraction) {
await interaction.deferReply();
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]});
await interaction.editReply({components: [await statusService.getUpdatedContainer()], flags: MessageFlags.IsComponentsV2});
}
}
}
export default cmd;

23
src/data-source.ts Normal file
View File

@@ -0,0 +1,23 @@
import "reflect-metadata"
import { DataSource } from "typeorm"
import { configDotenv } from "dotenv"
import { Follow } from "./entity/follow.entity"
import { Guild } from "./entity/guild.entity"
import { HostsLog } from "./entity/hostslog.entity"
import { Service } from "./entity/service.entity"
configDotenv()
export const AppDataSource = new DataSource({
type: "postgres",
host: process.env.DB_HOST || "localhost",
port: parseInt(process.env.DB_PORT || "5432"),
username: process.env.DB_USERNAME || "postgres",
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
synchronize: process.env.NODE_ENV !== "production",
logging: process.env.DB_LOGGING === "true",
entities: [Follow, Guild, HostsLog, Service],
migrations: [__dirname + "/**/*.migration.js"],
subscribers: [__dirname + "/**/*.subscriber.js"],
})

View File

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

View File

@@ -0,0 +1,21 @@
import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
import { Service } from "./service.entity";
@Entity({name: 'follows'})
export class Follow {
@PrimaryGeneratedColumn()
id: number;
@Column()
user_discord: string;
@ManyToOne(() => Service, service => service.follows)
@JoinColumn({name: 'serviceId'})
service: Service;
@Column()
serviceId: number;
@Column({default: false})
enable: boolean;
}

View File

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

View File

@@ -0,0 +1,21 @@
import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
import { Service } from "./service.entity";
@Entity({name: 'hosts_logs'})
export class HostsLog {
@PrimaryGeneratedColumn()
id: number;
@ManyToOne(() => Service, service => service.logs)
@JoinColumn({name: 'serviceId'})
service: Service;
@Column()
serviceId: number;
@Column()
status: boolean;
@CreateDateColumn()
created_at: Date;
}

View File

@@ -0,0 +1,34 @@
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
import { HostsLog } from "./hostslog.entity";
import { Follow } from "./follow.entity";
@Entity({name: 'services'})
export class Service {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column()
host: string;
@Column()
alive: boolean;
@Column()
ping_type: string;
@Column()
type: string;
@Column()
notify: boolean
@OneToMany(() => HostsLog, log => log.service)
logs: HostsLog[];
@OneToMany(() => Follow, follow => follow.service)
follows: Follow[];
}

View File

@@ -4,8 +4,18 @@ import path from "path";
import fs from "fs";
import statusService from "./services/status.service";
import "reflect-metadata";
import { AppDataSource } from "./data-source";
import { CommandDefinition } from "./type";
configDotenv();
AppDataSource.initialize()
.then(() => {
console.log("Data Source initialized !")
})
.catch((error) => console.log(error));
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
@@ -15,8 +25,7 @@ const client = new Client({
]
});
// @ts-expect-error
client.commands = new Collection();
const commands = new Collection<string, CommandDefinition>();
const foldersPath = path.join(__dirname, 'commands');
const commandFolders = fs.readdirSync(foldersPath);
@@ -29,8 +38,7 @@ for (const folder of commandFolders) {
const command = require(filePath);
const commandModule = command.default || command;
if ('data' in commandModule && 'execute' in commandModule) {
// @ts-expect-error
client.commands.set(commandModule.data.name, commandModule);
commands.set(commandModule.data.name, commandModule);
} else {
console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`);
}
@@ -38,10 +46,26 @@ for (const folder of commandFolders) {
}
client.on(Events.InteractionCreate, async interaction => {
if(interaction.isButton()) {
const id = interaction.customId;
commands.forEach((value, key) => {
if(value.buttons) {
const button = value.buttons.filter((b) => b.id == id);
if(button.length == 1) {
button[0]?.handle(interaction);
}
}
});
return;
}
if (!interaction.isChatInputCommand()) return;
// @ts-expect-error
const command = interaction.client.commands.get(interaction.commandName);
const command = commands.get(interaction.commandName);
if (!command) {
console.error(`No command matching ${interaction.commandName} was found.`);
@@ -62,6 +86,9 @@ client.on(Events.InteractionCreate, async interaction => {
client.once(Events.ClientReady, readyClient => {
console.log(`Ready! Logged in as ${readyClient.user.tag}`);
client.guilds.cache.forEach((value) => {
console.log(`${value.name} conf loaded !`);
});
statusService.setClient(client);
});

View File

@@ -1,78 +1,72 @@
import ping from "ping";
import * as cron from 'cron';
import { ActivityType, Client } from "discord.js";
import { ActivityType, Client, ContainerBuilder, MessageFlags } from "discord.js";
import { 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, { Dayjs } from "dayjs";
import { Canvas } from "canvas";
import { Service } from "../entity/service.entity";
type Nofity = {time: Date, name : string, alive : boolean, type : string, host: Service};
export class StatusService {
public hosts : Host[] = [
{
'host': 'https://protojx.com',
'name': 'Protojx Website 🌐',
alive: false,
type: 'website'
},
{
'host': 'https://manager.protojx.com',
'name': 'Espace Client 💻',
alive: false,
type: 'website'
},
{
host: '5.178.99.4',
name: 'RYZEN 01 🖥️',
alive: false,
type: 'ping'
},
{
host: '5.178.99.6',
name: 'RYZEN 02 🖥️',
alive: false,
type: 'ping'
},
{
host: '5.178.99.5',
name: 'RYZEN 03 🖥️',
alive: false,
type: 'ping'
},
{
host: '5.178.99.177',
name: 'XEON 01 (2697A V4) 🖥️',
alive: false,
type: 'ping'
},
{
host: '5.178.99.248',
name: 'XEON 02 (2687W V4) 🖥️',
alive: false,
type: 'ping'
},
{
host: '5.178.99.53',
name: 'RYZEN-GAME 01 👾',
alive: false,
type: 'ping'
},
{
host: '5.178.99.63',
name: 'XEON-GAME 01 👾',
alive: false,
type: 'ping'
}
]
private client: Client | null = null;
private hostsLogRepo: Repository<HostsLog>;
private followRepo: Repository<Follow>;
private guildRepo: Repository<Guild>;
public serviceRepo: Repository<Service>;
private client : Client|null = null;
constructor() {
this.hostsLogRepo = AppDataSource.getRepository(HostsLog);
this.followRepo = AppDataSource.getRepository(Follow);
this.guildRepo = AppDataSource.getRepository(Guild);
this.serviceRepo = AppDataSource.getRepository(Service);
setTimeout(async () => {
await this.fetch()
this.updateClientStatus();
}, 3000);
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 {
await this.fetch();
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: [await this.getUpdatedContainer(true)]});
}
} catch (error) {
console.log(error + ' GuildIdInDB : '+gdb.id);
}
}
});
console.log('Status check completed at:', new Date().toISOString());
} catch (error) {
console.error('Error during status check:', error);
@@ -80,57 +74,233 @@ export class StatusService {
});
cronJob.start();
console.log('Status monitoring started - checking every 2 minutes');
}
}
setClient(client : Client) {
setClient(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() {
if(this.client) {
const hosts = this.hosts.length;
const hostsAlive = this.hosts.filter((h) => h.alive).length;
public async getStatusImageBar(host: string) {
this.client.user?.setActivity({name: (
hosts == hostsAlive ? '✅ All services are online.' : `📛 ${hosts - hostsAlive} service${hosts - hostsAlive > 1 ? 's' : ''} offline.`
), type: ActivityType.Watching});
}
}
private async fetch(max = 1): Promise<Host[]> {
const datas = await this.hostsLogRepo.createQueryBuilder()
.where('host = :host AND created_at > :date', {host, date: dayjs().subtract(1, 'week').toDate()}).getMany();
const hosts = this.hosts.filter((value, index) => index < max * 10 && index >= (max - 1) * 10);
async function fetchAlive(host: Host) {
if(host.type === 'ping'){
let res = await ping.promise.probe(host.host, {timeout: 3});
host.alive = res.alive;
}else if(host.type === 'website'){
try {
const response = await fetch(host.host, { method: 'HEAD', signal: AbortSignal.timeout(3000) });
host.alive = response.ok;
} catch (error) {
host.alive = false;
}
}
return host;
}
const uptimes : { up: boolean, date: Dayjs }[] = datas.map((log) => {
const fetchPromises = hosts.map(host => fetchAlive(host));
const updatedHosts = await Promise.all(fetchPromises);
updatedHosts.forEach((updatedHost, index) => {
const originalIndex = (max - 1) * 10 + index;
if (originalIndex < this.hosts.length) {
this.hosts[originalIndex] = updatedHost;
return {
up: log.status,
date: dayjs(log.created_at)
}
});
if(this.hosts.length > max * 10) {
await this.fetch(max + 1);
const now = dayjs();
const week = now.clone().subtract(1, 'week');
const canvas = new Canvas(500, 10, "image");
const ctx = canvas.getContext('2d');
ctx.fillStyle = "#27FF00";
ctx.fillRect(0, 0, canvas.width, canvas.height);
const maxTime = (now.unix() - week.unix());
const ranges: { min: number, max: number }[] = [];
let minTime: number | null = null;
uptimes.map((element, index) => {
const positionForMaxTime = (element.date.unix() - week.unix());
const percent = Math.round((positionForMaxTime / maxTime) * 100);
if (ranges.length == 0 && minTime == null) {
if (element.up && minTime == null) {
ranges.push({
min: 0,
max: percent
});
} else {
minTime = percent;
}
} else {
if (!element.up) {
minTime = percent;
if(minTime != null && index == uptimes.length - 1) {
ranges.push({
min: minTime,
max: 100
});
}
} else {
if (minTime) {
ranges.push({
min: minTime,
max: percent
});
}
}
}
});
ctx.fillStyle = '#ff0000';
ranges.map((value) => {
ctx.fillRect(value.min * 5, 0, value.max * 5 - value.min * 5, canvas.height);
});
return canvas.toBuffer('image/png');
}
private async updateClientStatus() {
if (this.client) {
const hosts_db = await this.serviceRepo.find();
const hosts = hosts_db.length;
const hostsAlive = hosts_db.filter((h) => h.alive).length;
this.client.user?.setActivity({
name: (
hosts == hostsAlive ? '✅ All services are online.' : `📛 ${hosts - hostsAlive} service${hosts - hostsAlive > 1 ? 's' : ''} offline.`
), type: ActivityType.Watching
});
}
}
private async fetchAlive(service: Service, notifs : Nofity[]) {
const latestLog = await this.hostsLogRepo.findOne({ where: { service }, order: { created_at: 'DESC' } });
// ? Ping and Request Hosts
if (service.ping_type === 'ping') {
let res = await ping.promise.probe(service.host, { timeout: 10 });
service.alive = res.alive;
} else if (service.ping_type === 'website') {
try {
const response = await fetch(service.host, { method: 'HEAD', signal: AbortSignal.timeout(10000) });
service.alive = response.ok;
} catch (error) {
service.alive = false;
}
}
return this.hosts;
// ? Notification System :
if (!latestLog || latestLog.status != service.alive) {
const log = new HostsLog();
log.service = service;
log.status = service.alive;
this.hostsLogRepo.save(log);
if(latestLog && service.notify) {
notifs.push({alive: service.alive, name: service.name, time: new Date(), type: service.type, host: service});
}
}
this.serviceRepo.save(service);
return service;
}
private async fetch(max = 1, notifs : Nofity[] = []) {
const max_ping = 3;
const services = await this.serviceRepo.find();
const hosts = services.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);
updatedHosts.forEach((updatedHost, index) => {
const originalIndex = (max - 1) * max_ping + index;
if (originalIndex < services.length) {
services[originalIndex] = updatedHost;
}
});
if (services.length > max * max_ping) {
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.service)).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) {}
}
});
}
}
public async getUpdatedContainer(live : boolean = false): Promise<ContainerBuilder> {
const services = await this.serviceRepo.find({order: {id: 'ASC'}});
const hostTexts = services.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'
},
{
title: 'Routers\n-# *The data displayed here is not real data but demonstration data. (Beta)*',
type: 'router',
thumbnail: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRMnCmtQRkLlcD1Cb6vKXz6NOxAu79vzmq2pRqpNYxpTJa5JQEsouhqnVn7cyl6ivYSyzY&usqp=CAU'
}
]
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;
}
}

5
src/type.d.ts vendored
View File

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

View File

@@ -2,9 +2,9 @@
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"target": "ES2020",
"target": "ES2024",
"module": "CommonJS",
"lib": ["ES2020"],
"lib": ["ES2024"],
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
@@ -24,7 +24,9 @@
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"removeComments": false,
"preserveConstEnums": true
"preserveConstEnums": true,
"strictPropertyInitialization": false,
"noUnusedLocals": true
},
"include": [
"src/**/*"