43 Commits

Author SHA1 Message Date
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
18 changed files with 2068 additions and 100 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_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

View File

@@ -1,5 +1,7 @@
FROM node:22
WORKDIR /app
COPY . .
RUN apt-get update && apt-get install -y iputils-ping

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

1519
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,13 +24,17 @@
},
"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",
"cron": "^4.3.3",
"discord.js": "^14.24.1",
"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,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,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: [statusService.getUpdatedContainer()], flags: MessageFlags.IsComponentsV2});
}
}
export default cmd;

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

@@ -0,0 +1,19 @@
import "reflect-metadata"
import { DataSource } from "typeorm"
import { configDotenv } from "dotenv"
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: [__dirname + '/**/*.entity.js'],
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,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

@@ -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,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

@@ -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,156 @@
import ping from "ping";
import * as cron from 'cron';
import { ActivityType, Client } from "discord.js";
import { ActivityType, Client, ContainerBuilder, MessageFlags } from "discord.js";
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";
type Nofity = {time: Date, name : string, alive : boolean, type : InfraType, host: string};
export class StatusService {
public hosts : Host[] = [
public hosts: Host[] = [
{
'host': 'https://protojx.com',
'name': 'Protojx Website 🌐',
'name': 'Protojx Website',
alive: false,
type: 'website'
ping_type: 'website',
type: 'website',
notify: false
},
{
'host': 'https://manager.protojx.com',
'name': 'Espace Client 💻',
'name': 'Espace Client',
alive: false,
type: 'website'
ping_type: 'website',
type: 'website',
notify: false
},
{
host: '5.178.99.4',
name: 'RYZEN 01 🖥️',
name: 'RYZEN 01',
alive: false,
type: 'ping'
ping_type: 'ping',
type: 'ryzen',
notify: true
},
{
host: '5.178.99.6',
name: 'RYZEN 02 🖥️',
name: 'RYZEN 02',
alive: false,
type: 'ping'
ping_type: 'ping',
type: 'ryzen',
notify: true
},
{
host: '5.178.99.5',
name: 'RYZEN 03 🖥️',
name: 'RYZEN 03',
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',
name: 'XEON 01 (2697A V4) 🖥️',
name: 'XEON 01',
alive: false,
type: 'ping'
ping_type: 'ping',
type: 'xeon',
notify: true
},
{
host: '5.178.99.248',
name: 'XEON 02 (2687W V4) 🖥️',
host: '154.16.254.45',
name: 'XEON 02',
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',
name: 'RYZEN-GAME 01 👾',
name: 'RYZEN-GAME 01',
alive: false,
type: 'ping'
ping_type: 'ping',
type: 'games',
notify: true
},
{
host: '5.178.99.63',
name: 'XEON-GAME 01 👾',
name: 'XEON-GAME 01',
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() {
this.hostsLogRepo = AppDataSource.getRepository(HostsLog);
this.followRepo = AppDataSource.getRepository(Follow);
this.guildRepo = AppDataSource.getRepository(Guild);
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: [this.getUpdatedContainer(true)]});
}
} catch (error) {}
}
});
console.log('Status check completed at:', new Date().toISOString());
} catch (error) {
console.error('Error during status check:', error);
@@ -82,55 +160,153 @@ export class StatusService {
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) {
if (this.client) {
const hosts = this.hosts.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.`
), 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);
async function fetchAlive(host: Host) {
if(host.type === 'ping'){
let res = await ping.promise.probe(host.host, {timeout: 3});
const latestLog = await this.hostsLogRepo.findOne({ where: { host: host.host }, order: { created_at: 'DESC' } });
// ? Ping and Request Hosts
if (host.ping_type === 'ping') {
let res = await ping.promise.probe(host.host, { timeout: 10 });
host.alive = res.alive;
}else if(host.type === 'website'){
} else if (host.ping_type === 'website') {
try {
const response = await fetch(host.host, { method: 'HEAD', signal: AbortSignal.timeout(3000) });
const response = await fetch(host.host, { method: 'HEAD', signal: AbortSignal.timeout(10000) });
host.alive = response.ok;
} catch (error) {
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;
}
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);
updatedHosts.forEach((updatedHost, index) => {
const originalIndex = (max - 1) * 10 + index;
const originalIndex = (max - 1) * max_ping + index;
if (originalIndex < this.hosts.length) {
this.hosts[originalIndex] = updatedHost;
}
});
if(this.hosts.length > max * 10) {
await this.fetch(max + 1);
if (this.hosts.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.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)
)
)
});
const now = new Date();
container.addTextDisplayComponents((text) => text.setContent(`${live ? 'Last update : ' : ''}${now.getDate()}-${now.getMonth() + 1}-${now.getFullYear()} ${(now.getHours() + '').padStart(2, "0")}:${(now.getMinutes() + '').padStart(2, "0")} - Receive automatic notifications when there is an outage with /follow !`));
return container;
}
}

13
src/type.d.ts vendored
View File

@@ -1 +1,12 @@
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';
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

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