Add Aethex Sentinel bot with security and federation features
Initializes the Aethex Sentinel bot project, including package.json, Prisma schema, core client, configuration, health server, and event listeners for audit logs and member updates. Implements commands for federation management, sentinel security status, and ticket system. Replit-Commit-Author: Agent Replit-Commit-Session-Id: e72fc1b7-94bd-4d6c-801f-cbac2fae245c Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: fc082e4d-cb52-4049-9c2e-69ba6c2e78d4 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3bdfff67-975a-46ad-9845-fbb6b4a4c4b5/e72fc1b7-94bd-4d6c-801f-cbac2fae245c/jW8PJKQ Replit-Helium-Checkpoint-Created: true
This commit is contained in:
parent
6a79a12c06
commit
fbd203dcfc
22 changed files with 2438 additions and 1 deletions
2
.replit
2
.replit
|
|
@ -1,5 +1,5 @@
|
|||
entrypoint = "main.py"
|
||||
modules = ["python-3.10", "postgresql-16"]
|
||||
modules = ["python-3.10", "postgresql-16", "nodejs-20"]
|
||||
|
||||
hidden = [".pythonlibs"]
|
||||
|
||||
|
|
|
|||
21
sentinel-bot/.env.example
Normal file
21
sentinel-bot/.env.example
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Discord Bot Configuration
|
||||
DISCORD_TOKEN=your_bot_token_here
|
||||
|
||||
# Federation Guild IDs
|
||||
HUB_ID=your_hub_server_id
|
||||
FORGE_ID=your_gameforge_server_id
|
||||
FOUNDATION_ID=your_foundation_server_id
|
||||
LABS_ID=your_labs_server_id
|
||||
CORP_ID=your_corp_server_id
|
||||
|
||||
# Security Configuration
|
||||
WHITELISTED_USERS=user_id_1,user_id_2
|
||||
|
||||
# Dashboard Configuration
|
||||
STATUS_CHANNEL_ID=voice_channel_id_for_status
|
||||
|
||||
# Health Check
|
||||
HEALTH_PORT=8044
|
||||
|
||||
# Database (auto-provided by Replit)
|
||||
# DATABASE_URL=postgresql://...
|
||||
1097
sentinel-bot/package-lock.json
generated
Normal file
1097
sentinel-bot/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
sentinel-bot/package.json
Normal file
27
sentinel-bot/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "aethex-sentinel",
|
||||
"version": "1.0.0",
|
||||
"description": "Enterprise-grade Discord bot for managing a federation of servers",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node src/index.ts",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@sapphire/framework": "^5.2.1",
|
||||
"@sapphire/plugin-scheduled-tasks": "^10.0.1",
|
||||
"discord.js": "^14.16.3",
|
||||
"dotenv": "^16.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.17.6",
|
||||
"prisma": "^5.22.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
65
sentinel-bot/prisma/schema.prisma
Normal file
65
sentinel-bot/prisma/schema.prisma
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id
|
||||
heatLevel Int @default(0)
|
||||
balance Float @default(0.0)
|
||||
roles String[]
|
||||
lastHeatReset DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tickets Ticket[]
|
||||
heatEvents HeatEvent[]
|
||||
}
|
||||
|
||||
model HeatEvent {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String
|
||||
action String
|
||||
timestamp DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([userId, timestamp])
|
||||
}
|
||||
|
||||
model Ticket {
|
||||
id Int @id @default(autoincrement())
|
||||
threadId String @unique
|
||||
userId String
|
||||
type String
|
||||
status String @default("open")
|
||||
transcript String?
|
||||
createdAt DateTime @default(now())
|
||||
closedAt DateTime?
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model GuildConfig {
|
||||
id String @id
|
||||
name String
|
||||
type String
|
||||
statusChannelId String?
|
||||
feedChannelId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model RoleMapping {
|
||||
id Int @id @default(autoincrement())
|
||||
sourceGuild String
|
||||
sourceRole String
|
||||
targetGuild String
|
||||
targetRole String
|
||||
roleName String
|
||||
|
||||
@@unique([sourceGuild, sourceRole, targetGuild])
|
||||
}
|
||||
102
sentinel-bot/src/commands/federation.ts
Normal file
102
sentinel-bot/src/commands/federation.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { Command } from '@sapphire/framework';
|
||||
import { PermissionFlagsBits } from 'discord.js';
|
||||
import { federationManager } from '../modules/federation/FederationManager';
|
||||
import { prisma } from '../core/client';
|
||||
|
||||
export class FederationCommand extends Command {
|
||||
public constructor(context: Command.LoaderContext, options: Command.Options) {
|
||||
super(context, {
|
||||
...options,
|
||||
name: 'federation',
|
||||
description: 'Manage federation role mappings',
|
||||
requiredUserPermissions: [PermissionFlagsBits.Administrator],
|
||||
});
|
||||
}
|
||||
|
||||
public override registerApplicationCommands(registry: Command.Registry) {
|
||||
registry.registerChatInputCommand((builder) =>
|
||||
builder
|
||||
.setName(this.name)
|
||||
.setDescription(this.description)
|
||||
.addSubcommand((sub) =>
|
||||
sub
|
||||
.setName('add-mapping')
|
||||
.setDescription('Add a role mapping between guilds')
|
||||
.addStringOption((opt) =>
|
||||
opt.setName('source-guild').setDescription('Source guild ID').setRequired(true)
|
||||
)
|
||||
.addStringOption((opt) =>
|
||||
opt.setName('source-role').setDescription('Source role ID').setRequired(true)
|
||||
)
|
||||
.addStringOption((opt) =>
|
||||
opt.setName('target-guild').setDescription('Target guild ID').setRequired(true)
|
||||
)
|
||||
.addStringOption((opt) =>
|
||||
opt.setName('target-role').setDescription('Target role ID').setRequired(true)
|
||||
)
|
||||
.addStringOption((opt) =>
|
||||
opt.setName('role-name').setDescription('Friendly name for this mapping').setRequired(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand((sub) =>
|
||||
sub.setName('list').setDescription('List all role mappings')
|
||||
)
|
||||
.addSubcommand((sub) =>
|
||||
sub.setName('reload').setDescription('Reload mappings from database')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
if (subcommand === 'add-mapping') {
|
||||
const sourceGuild = interaction.options.getString('source-guild', true);
|
||||
const sourceRole = interaction.options.getString('source-role', true);
|
||||
const targetGuild = interaction.options.getString('target-guild', true);
|
||||
const targetRole = interaction.options.getString('target-role', true);
|
||||
const roleName = interaction.options.getString('role-name', true);
|
||||
|
||||
await federationManager.addMapping({
|
||||
sourceGuild,
|
||||
sourceRole,
|
||||
targetGuild,
|
||||
targetRole,
|
||||
roleName,
|
||||
});
|
||||
|
||||
return interaction.reply({
|
||||
content: `✅ Added mapping: "${roleName}" from ${sourceGuild} to ${targetGuild}`,
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (subcommand === 'list') {
|
||||
const mappings = await prisma.roleMapping.findMany();
|
||||
|
||||
if (mappings.length === 0) {
|
||||
return interaction.reply({
|
||||
content: 'No role mappings configured.',
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
|
||||
const list = mappings
|
||||
.map((m) => `• **${m.roleName}**: ${m.sourceGuild}:${m.sourceRole} → ${m.targetGuild}:${m.targetRole}`)
|
||||
.join('\n');
|
||||
|
||||
return interaction.reply({
|
||||
content: `**Federation Role Mappings:**\n${list}`,
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (subcommand === 'reload') {
|
||||
await federationManager.loadMappings();
|
||||
return interaction.reply({
|
||||
content: '✅ Reloaded role mappings from database.',
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
84
sentinel-bot/src/commands/sentinel.ts
Normal file
84
sentinel-bot/src/commands/sentinel.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { Command } from '@sapphire/framework';
|
||||
import { PermissionFlagsBits, EmbedBuilder } from 'discord.js';
|
||||
import { heatSystem } from '../modules/security/HeatSystem';
|
||||
import { config } from '../core/config';
|
||||
|
||||
export class SentinelCommand extends Command {
|
||||
public constructor(context: Command.LoaderContext, options: Command.Options) {
|
||||
super(context, {
|
||||
...options,
|
||||
name: 'sentinel',
|
||||
description: 'Manage the Sentinel security system',
|
||||
requiredUserPermissions: [PermissionFlagsBits.Administrator],
|
||||
});
|
||||
}
|
||||
|
||||
public override registerApplicationCommands(registry: Command.Registry) {
|
||||
registry.registerChatInputCommand((builder) =>
|
||||
builder
|
||||
.setName(this.name)
|
||||
.setDescription(this.description)
|
||||
.addSubcommand((sub) =>
|
||||
sub.setName('status').setDescription('View Sentinel security status')
|
||||
)
|
||||
.addSubcommand((sub) =>
|
||||
sub
|
||||
.setName('clear-heat')
|
||||
.setDescription('Clear heat level for a user')
|
||||
.addUserOption((opt) =>
|
||||
opt.setName('user').setDescription('User to clear heat for').setRequired(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand((sub) =>
|
||||
sub.setName('unlock').setDescription('Lift lockdown mode on this server')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
if (subcommand === 'status') {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x00ff00)
|
||||
.setTitle('🛡️ Sentinel Security Status')
|
||||
.addFields(
|
||||
{ name: 'Heat Threshold', value: `${config.security.heatThreshold} actions`, inline: true },
|
||||
{ name: 'Window', value: `${config.security.heatWindowMs}ms`, inline: true },
|
||||
{ name: 'Whitelisted Users', value: `${config.security.whitelistedUsers.length}`, inline: true },
|
||||
{
|
||||
name: 'Monitored Actions',
|
||||
value: config.security.dangerousActions.join(', '),
|
||||
inline: false,
|
||||
}
|
||||
)
|
||||
.setTimestamp();
|
||||
|
||||
return interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
}
|
||||
|
||||
if (subcommand === 'clear-heat') {
|
||||
const user = interaction.options.getUser('user', true);
|
||||
heatSystem.clearHeat(user.id);
|
||||
return interaction.reply({
|
||||
content: `✅ Cleared heat for ${user.tag}`,
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (subcommand === 'unlock') {
|
||||
if (!interaction.guild) {
|
||||
return interaction.reply({
|
||||
content: 'This command must be used in a server.',
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
|
||||
await heatSystem.unlockGuild(interaction.guild);
|
||||
return interaction.reply({
|
||||
content: '🔓 Lockdown lifted. @everyone permissions restored.',
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
55
sentinel-bot/src/commands/status.ts
Normal file
55
sentinel-bot/src/commands/status.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { Command } from '@sapphire/framework';
|
||||
import { EmbedBuilder } from 'discord.js';
|
||||
import { config } from '../core/config';
|
||||
|
||||
export class StatusCommand extends Command {
|
||||
public constructor(context: Command.LoaderContext, options: Command.Options) {
|
||||
super(context, {
|
||||
...options,
|
||||
name: 'status',
|
||||
description: 'View Sentinel bot and network status',
|
||||
});
|
||||
}
|
||||
|
||||
public override registerApplicationCommands(registry: Command.Registry) {
|
||||
registry.registerChatInputCommand((builder) =>
|
||||
builder.setName(this.name).setDescription(this.description)
|
||||
);
|
||||
}
|
||||
|
||||
public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) {
|
||||
const client = this.container.client;
|
||||
|
||||
const guildIds = Object.values(config.guilds).filter(id => id);
|
||||
const guilds = guildIds
|
||||
.map(id => client.guilds.cache.get(id))
|
||||
.filter(g => g !== undefined);
|
||||
|
||||
const totalMembers = guilds.reduce((sum, g) => sum + g!.memberCount, 0);
|
||||
|
||||
const guildList = guilds
|
||||
.map(g => `• **${g!.name}**: ${g!.memberCount.toLocaleString()} members`)
|
||||
.join('\n') || 'No federation guilds connected';
|
||||
|
||||
const uptimeSeconds = Math.floor(process.uptime());
|
||||
const hours = Math.floor(uptimeSeconds / 3600);
|
||||
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
|
||||
const seconds = uptimeSeconds % 60;
|
||||
const uptimeStr = `${hours}h ${minutes}m ${seconds}s`;
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x5865f2)
|
||||
.setTitle('🛡️ Aethex Sentinel Status')
|
||||
.setThumbnail(client.user?.displayAvatarURL() || null)
|
||||
.addFields(
|
||||
{ name: '📡 Network', value: `${guilds.length} guilds`, inline: true },
|
||||
{ name: '👥 Total Members', value: totalMembers.toLocaleString(), inline: true },
|
||||
{ name: '⏱️ Uptime', value: uptimeStr, inline: true },
|
||||
{ name: '🏰 Federation Guilds', value: guildList, inline: false }
|
||||
)
|
||||
.setFooter({ text: 'Aethex Sentinel • Protecting the Federation' })
|
||||
.setTimestamp();
|
||||
|
||||
return interaction.reply({ embeds: [embed] });
|
||||
}
|
||||
}
|
||||
76
sentinel-bot/src/commands/ticket.ts
Normal file
76
sentinel-bot/src/commands/ticket.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { Command } from '@sapphire/framework';
|
||||
import { PermissionFlagsBits, ChannelType, TextChannel } from 'discord.js';
|
||||
import { ticketManager } from '../modules/commerce/TicketManager';
|
||||
import { prisma } from '../core/client';
|
||||
|
||||
export class TicketCommand extends Command {
|
||||
public constructor(context: Command.LoaderContext, options: Command.Options) {
|
||||
super(context, {
|
||||
...options,
|
||||
name: 'ticket',
|
||||
description: 'Manage the ticket system',
|
||||
requiredUserPermissions: [PermissionFlagsBits.Administrator],
|
||||
});
|
||||
}
|
||||
|
||||
public override registerApplicationCommands(registry: Command.Registry) {
|
||||
registry.registerChatInputCommand((builder) =>
|
||||
builder
|
||||
.setName(this.name)
|
||||
.setDescription(this.description)
|
||||
.addSubcommand((sub) =>
|
||||
sub
|
||||
.setName('setup')
|
||||
.setDescription('Set up the ticket panel in this channel')
|
||||
)
|
||||
.addSubcommand((sub) =>
|
||||
sub.setName('stats').setDescription('View ticket statistics')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
if (subcommand === 'setup') {
|
||||
if (!interaction.channel || interaction.channel.type !== ChannelType.GuildText) {
|
||||
return interaction.reply({
|
||||
content: 'This command must be used in a text channel.',
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
await ticketManager.createSupportPanel(interaction.channel as TextChannel);
|
||||
|
||||
return interaction.editReply({
|
||||
content: '✅ Ticket panel created!',
|
||||
});
|
||||
}
|
||||
|
||||
if (subcommand === 'stats') {
|
||||
const totalTickets = await prisma.ticket.count();
|
||||
const openTickets = await prisma.ticket.count({ where: { status: 'open' } });
|
||||
const closedTickets = await prisma.ticket.count({ where: { status: 'closed' } });
|
||||
|
||||
const byType = await prisma.ticket.groupBy({
|
||||
by: ['type'],
|
||||
_count: { id: true },
|
||||
});
|
||||
|
||||
const typeStats = byType
|
||||
.map((t) => `• ${t.type}: ${t._count.id}`)
|
||||
.join('\n') || 'No tickets yet';
|
||||
|
||||
return interaction.reply({
|
||||
content:
|
||||
`**🎫 Ticket Statistics**\n\n` +
|
||||
`Total: ${totalTickets}\n` +
|
||||
`Open: ${openTickets}\n` +
|
||||
`Closed: ${closedTickets}\n\n` +
|
||||
`**By Type:**\n${typeStats}`,
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
37
sentinel-bot/src/core/client.ts
Normal file
37
sentinel-bot/src/core/client.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { SapphireClient } from '@sapphire/framework';
|
||||
import { GatewayIntentBits, Partials } from 'discord.js';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
|
||||
export class SentinelClient extends SapphireClient {
|
||||
public constructor() {
|
||||
super({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMembers,
|
||||
GatewayIntentBits.GuildModeration,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
GatewayIntentBits.DirectMessages,
|
||||
],
|
||||
partials: [
|
||||
Partials.Channel,
|
||||
Partials.Message,
|
||||
Partials.GuildMember,
|
||||
],
|
||||
loadMessageCommandListeners: true,
|
||||
});
|
||||
}
|
||||
|
||||
public override async login(token?: string): Promise<string> {
|
||||
await prisma.$connect();
|
||||
console.log('✅ Database connected');
|
||||
return super.login(token);
|
||||
}
|
||||
|
||||
public override async destroy(): Promise<void> {
|
||||
await prisma.$disconnect();
|
||||
return super.destroy();
|
||||
}
|
||||
}
|
||||
41
sentinel-bot/src/core/config.ts
Normal file
41
sentinel-bot/src/core/config.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import 'dotenv/config';
|
||||
|
||||
export const config = {
|
||||
token: process.env.DISCORD_TOKEN!,
|
||||
|
||||
guilds: {
|
||||
hub: process.env.HUB_ID!,
|
||||
gameforge: process.env.FORGE_ID!,
|
||||
foundation: process.env.FOUNDATION_ID!,
|
||||
labs: process.env.LABS_ID!,
|
||||
corp: process.env.CORP_ID!,
|
||||
},
|
||||
|
||||
security: {
|
||||
heatThreshold: 3,
|
||||
heatWindowMs: 5000,
|
||||
dangerousActions: ['CHANNEL_DELETE', 'BAN_ADD', 'KICK', 'ROLE_DELETE', 'WEBHOOK_DELETE'],
|
||||
whitelistedUsers: process.env.WHITELISTED_USERS?.split(',') || [],
|
||||
},
|
||||
|
||||
dashboard: {
|
||||
updateIntervalMs: 5 * 60 * 1000,
|
||||
statusChannelId: process.env.STATUS_CHANNEL_ID,
|
||||
},
|
||||
|
||||
health: {
|
||||
port: parseInt(process.env.HEALTH_PORT || '8044'),
|
||||
},
|
||||
};
|
||||
|
||||
export function validateConfig(): void {
|
||||
const required = ['DISCORD_TOKEN'];
|
||||
const missing = required.filter(key => !process.env[key]);
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error('❌ Missing required environment variables:', missing.join(', '));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('✅ Configuration validated');
|
||||
}
|
||||
59
sentinel-bot/src/core/health.ts
Normal file
59
sentinel-bot/src/core/health.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import http from 'http';
|
||||
import { SentinelClient } from './client';
|
||||
import { config } from './config';
|
||||
|
||||
export function startHealthServer(client: SentinelClient): void {
|
||||
const server = http.createServer((req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === '/health') {
|
||||
const guilds = client.guilds.cache.size;
|
||||
const commands = client.stores.get('commands')?.size || 0;
|
||||
|
||||
res.writeHead(200);
|
||||
res.end(JSON.stringify({
|
||||
status: client.isReady() ? 'online' : 'offline',
|
||||
guilds,
|
||||
commands,
|
||||
uptime: Math.floor(process.uptime()),
|
||||
timestamp: new Date().toISOString(),
|
||||
bot: {
|
||||
tag: client.user?.tag || 'Not ready',
|
||||
id: client.user?.id,
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === '/stats') {
|
||||
const guildStats = client.guilds.cache.map(g => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
memberCount: g.memberCount,
|
||||
}));
|
||||
|
||||
res.writeHead(200);
|
||||
res.end(JSON.stringify({
|
||||
guilds: guildStats,
|
||||
totalMembers: guildStats.reduce((sum, g) => sum + g.memberCount, 0),
|
||||
uptime: Math.floor(process.uptime()),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end(JSON.stringify({ error: 'Not found' }));
|
||||
});
|
||||
|
||||
server.listen(config.health.port, () => {
|
||||
console.log(`🏥 Health server running on port ${config.health.port}`);
|
||||
});
|
||||
}
|
||||
27
sentinel-bot/src/index.ts
Normal file
27
sentinel-bot/src/index.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { config, validateConfig } from './core/config';
|
||||
import { SentinelClient } from './core/client';
|
||||
import { startHealthServer } from './core/health';
|
||||
|
||||
validateConfig();
|
||||
|
||||
const client = new SentinelClient();
|
||||
|
||||
client.once('ready', () => {
|
||||
console.log(`✅ Aethex Sentinel logged in as ${client.user?.tag}`);
|
||||
console.log(`📡 Watching ${client.guilds.cache.size} guild(s)`);
|
||||
|
||||
client.user?.setActivity('🛡️ Protecting the Federation', { type: 3 });
|
||||
|
||||
startHealthServer(client);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (error: Error) => {
|
||||
console.error('❌ Unhandled Promise Rejection:', error);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error: Error) => {
|
||||
console.error('❌ Uncaught Exception:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
client.login(config.token);
|
||||
40
sentinel-bot/src/listeners/auditLogCreate.ts
Normal file
40
sentinel-bot/src/listeners/auditLogCreate.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Listener } from '@sapphire/framework';
|
||||
import { AuditLogEvent, GuildAuditLogsEntry, Guild } from 'discord.js';
|
||||
import { heatSystem } from '../modules/security/HeatSystem';
|
||||
|
||||
type DangerousAction = 'CHANNEL_DELETE' | 'BAN_ADD' | 'KICK' | 'ROLE_DELETE' | 'WEBHOOK_DELETE';
|
||||
|
||||
const DANGEROUS_ACTIONS: Record<number, DangerousAction> = {
|
||||
[AuditLogEvent.ChannelDelete]: 'CHANNEL_DELETE',
|
||||
[AuditLogEvent.MemberBanAdd]: 'BAN_ADD',
|
||||
[AuditLogEvent.MemberKick]: 'KICK',
|
||||
[AuditLogEvent.RoleDelete]: 'ROLE_DELETE',
|
||||
[AuditLogEvent.WebhookDelete]: 'WEBHOOK_DELETE',
|
||||
};
|
||||
|
||||
export class AuditLogCreateListener extends Listener {
|
||||
public constructor(context: Listener.LoaderContext, options: Listener.Options) {
|
||||
super(context, {
|
||||
...options,
|
||||
event: 'guildAuditLogEntryCreate',
|
||||
});
|
||||
}
|
||||
|
||||
public async run(entry: GuildAuditLogsEntry, guild: Guild): Promise<void> {
|
||||
const actionType = DANGEROUS_ACTIONS[entry.action];
|
||||
if (!actionType) return;
|
||||
|
||||
const executorId = entry.executorId;
|
||||
if (!executorId) return;
|
||||
|
||||
if (executorId === this.container.client.user?.id) return;
|
||||
|
||||
console.log(`⚠️ Dangerous action detected: ${actionType} by ${executorId} in ${guild.name}`);
|
||||
|
||||
const triggered = await heatSystem.recordAction(executorId, actionType, guild);
|
||||
|
||||
if (triggered) {
|
||||
console.log(`🚨 Anti-nuke triggered for ${executorId} in ${guild.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
28
sentinel-bot/src/listeners/guildMemberUpdate.ts
Normal file
28
sentinel-bot/src/listeners/guildMemberUpdate.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Listener } from '@sapphire/framework';
|
||||
import { GuildMember, Role } from 'discord.js';
|
||||
import { federationManager } from '../modules/federation/FederationManager';
|
||||
|
||||
export class GuildMemberUpdateListener extends Listener {
|
||||
public constructor(context: Listener.LoaderContext, options: Listener.Options) {
|
||||
super(context, {
|
||||
...options,
|
||||
event: 'guildMemberUpdate',
|
||||
});
|
||||
}
|
||||
|
||||
public async run(oldMember: GuildMember, newMember: GuildMember): Promise<void> {
|
||||
const oldRoles = oldMember.roles.cache;
|
||||
const newRoles = newMember.roles.cache;
|
||||
|
||||
const addedRoles = newRoles.filter(role => !oldRoles.has(role.id));
|
||||
const removedRoles = oldRoles.filter(role => !newRoles.has(role.id));
|
||||
|
||||
for (const role of addedRoles.values()) {
|
||||
await federationManager.syncRoleGrant(newMember, role);
|
||||
}
|
||||
|
||||
for (const role of removedRoles.values()) {
|
||||
await federationManager.syncRoleRemove(newMember, role);
|
||||
}
|
||||
}
|
||||
}
|
||||
46
sentinel-bot/src/listeners/interactionCreate.ts
Normal file
46
sentinel-bot/src/listeners/interactionCreate.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { Listener } from '@sapphire/framework';
|
||||
import { Interaction } from 'discord.js';
|
||||
import { ticketManager } from '../modules/commerce/TicketManager';
|
||||
|
||||
export class InteractionCreateListener extends Listener {
|
||||
public constructor(context: Listener.LoaderContext, options: Listener.Options) {
|
||||
super(context, {
|
||||
...options,
|
||||
event: 'interactionCreate',
|
||||
});
|
||||
}
|
||||
|
||||
public async run(interaction: Interaction): Promise<void> {
|
||||
if (!interaction.isButton()) return;
|
||||
|
||||
const customId = interaction.customId;
|
||||
|
||||
try {
|
||||
if (customId === 'ticket_rental') {
|
||||
await ticketManager.createTicket(interaction, 'rental');
|
||||
} else if (customId === 'ticket_support') {
|
||||
await ticketManager.createTicket(interaction, 'support');
|
||||
} else if (customId === 'ticket_billing') {
|
||||
await ticketManager.createTicket(interaction, 'billing');
|
||||
} else if (customId === 'ticket_invoice') {
|
||||
await ticketManager.generateInvoice(interaction);
|
||||
} else if (customId === 'ticket_close') {
|
||||
await ticketManager.closeTicket(interaction);
|
||||
} else if (customId === 'invoice_pay') {
|
||||
await interaction.reply({
|
||||
content: '✅ Invoice marked as paid! (Mock transaction)',
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Button interaction error:', error);
|
||||
|
||||
if (!interaction.replied && !interaction.deferred) {
|
||||
await interaction.reply({
|
||||
content: 'An error occurred while processing your request.',
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
28
sentinel-bot/src/listeners/ready.ts
Normal file
28
sentinel-bot/src/listeners/ready.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Listener } from '@sapphire/framework';
|
||||
import { Client } from 'discord.js';
|
||||
import { federationManager } from '../modules/federation/FederationManager';
|
||||
import { StatusUpdater } from '../modules/dashboard/StatusUpdater';
|
||||
|
||||
export class ReadyListener extends Listener {
|
||||
public constructor(context: Listener.LoaderContext, options: Listener.Options) {
|
||||
super(context, {
|
||||
...options,
|
||||
once: true,
|
||||
event: 'ready',
|
||||
});
|
||||
}
|
||||
|
||||
public async run(client: Client<true>): Promise<void> {
|
||||
console.log(`✅ Aethex Sentinel ready as ${client.user.tag}`);
|
||||
console.log(`📡 Connected to ${client.guilds.cache.size} guild(s)`);
|
||||
|
||||
await federationManager.loadMappings();
|
||||
|
||||
const statusUpdater = new StatusUpdater(client);
|
||||
statusUpdater.start();
|
||||
|
||||
client.user.setActivity('🛡️ Protecting the Federation', { type: 3 });
|
||||
|
||||
console.log('✅ All modules initialized');
|
||||
}
|
||||
}
|
||||
202
sentinel-bot/src/modules/commerce/TicketManager.ts
Normal file
202
sentinel-bot/src/modules/commerce/TicketManager.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import {
|
||||
ButtonInteraction,
|
||||
ChannelType,
|
||||
EmbedBuilder,
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
TextChannel,
|
||||
ThreadChannel,
|
||||
Message,
|
||||
} from 'discord.js';
|
||||
import { prisma } from '../../core/client';
|
||||
|
||||
export class TicketManager {
|
||||
async createTicket(interaction: ButtonInteraction, type: string): Promise<void> {
|
||||
const channel = interaction.channel as TextChannel;
|
||||
if (!channel || channel.type !== ChannelType.GuildText) {
|
||||
await interaction.reply({ content: 'Cannot create ticket here.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { id: interaction.user.id },
|
||||
update: {},
|
||||
create: { id: interaction.user.id },
|
||||
});
|
||||
|
||||
const thread = await channel.threads.create({
|
||||
name: `${type}-${interaction.user.username}-${Date.now().toString(36)}`,
|
||||
type: ChannelType.PrivateThread,
|
||||
invitable: false,
|
||||
reason: `Ticket created by ${interaction.user.tag}`,
|
||||
});
|
||||
|
||||
await thread.members.add(interaction.user.id);
|
||||
|
||||
await prisma.ticket.create({
|
||||
data: {
|
||||
threadId: thread.id,
|
||||
userId: interaction.user.id,
|
||||
type,
|
||||
status: 'open',
|
||||
},
|
||||
});
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x5865f2)
|
||||
.setTitle(`🎫 ${type.charAt(0).toUpperCase() + type.slice(1)} Ticket`)
|
||||
.setDescription(
|
||||
`Welcome ${interaction.user}!\n\n` +
|
||||
`A staff member will be with you shortly.\n` +
|
||||
`Please describe your request in detail.`
|
||||
)
|
||||
.setFooter({ text: `Ticket ID: ${thread.id}` })
|
||||
.setTimestamp();
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('ticket_invoice')
|
||||
.setLabel('Generate Invoice')
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
.setEmoji('📄'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId('ticket_close')
|
||||
.setLabel('Close Ticket')
|
||||
.setStyle(ButtonStyle.Danger)
|
||||
.setEmoji('🔒')
|
||||
);
|
||||
|
||||
await thread.send({ embeds: [embed], components: [row] });
|
||||
|
||||
await interaction.reply({
|
||||
content: `✅ Your ticket has been created: ${thread}`,
|
||||
ephemeral: true,
|
||||
});
|
||||
|
||||
console.log(`🎫 Ticket created: ${thread.name} by ${interaction.user.tag}`);
|
||||
}
|
||||
|
||||
async generateInvoice(interaction: ButtonInteraction): Promise<void> {
|
||||
const invoiceId = `INV-${Date.now().toString(36).toUpperCase()}`;
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x00ff00)
|
||||
.setTitle('📄 Invoice Generated')
|
||||
.setDescription('Here is your mock invoice for the requested service.')
|
||||
.addFields(
|
||||
{ name: 'Invoice ID', value: invoiceId, inline: true },
|
||||
{ name: 'Status', value: 'Pending', inline: true },
|
||||
{ name: 'Amount', value: '$XX.XX', inline: true },
|
||||
{ name: 'Service', value: 'Server Rental', inline: false },
|
||||
)
|
||||
.setFooter({ text: 'This is a mock invoice for demonstration' })
|
||||
.setTimestamp();
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('invoice_pay')
|
||||
.setLabel('Mark as Paid')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setEmoji('💳')
|
||||
);
|
||||
|
||||
await interaction.reply({ embeds: [embed], components: [row] });
|
||||
}
|
||||
|
||||
async closeTicket(interaction: ButtonInteraction): Promise<void> {
|
||||
const thread = interaction.channel as ThreadChannel;
|
||||
if (!thread || thread.type !== ChannelType.PrivateThread) {
|
||||
await interaction.reply({ content: 'This is not a ticket thread.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
where: { threadId: thread.id },
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
await interaction.reply({ content: 'Ticket not found in database.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({ content: '🔒 Closing ticket and saving transcript...' });
|
||||
|
||||
const transcript = await this.generateTranscript(thread);
|
||||
|
||||
await prisma.ticket.update({
|
||||
where: { threadId: thread.id },
|
||||
data: {
|
||||
status: 'closed',
|
||||
transcript,
|
||||
closedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await thread.setLocked(true, 'Ticket closed');
|
||||
await thread.setArchived(true, 'Ticket closed');
|
||||
|
||||
console.log(`🎫 Ticket closed: ${thread.name}`);
|
||||
}
|
||||
|
||||
private async generateTranscript(thread: ThreadChannel): Promise<string> {
|
||||
const messages: Message[] = [];
|
||||
let lastId: string | undefined;
|
||||
|
||||
while (true) {
|
||||
const batch = await thread.messages.fetch({
|
||||
limit: 100,
|
||||
before: lastId,
|
||||
});
|
||||
|
||||
if (batch.size === 0) break;
|
||||
|
||||
messages.push(...batch.values());
|
||||
lastId = batch.last()?.id;
|
||||
}
|
||||
|
||||
messages.reverse();
|
||||
|
||||
const transcript = messages
|
||||
.map(m => `[${m.createdAt.toISOString()}] ${m.author.tag}: ${m.content}`)
|
||||
.join('\n');
|
||||
|
||||
return transcript;
|
||||
}
|
||||
|
||||
async createSupportPanel(channel: TextChannel): Promise<void> {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x5865f2)
|
||||
.setTitle('🎫 Support Center')
|
||||
.setDescription(
|
||||
'Need help? Click a button below to open a support ticket.\n\n' +
|
||||
'**Available Services:**\n' +
|
||||
'• Server Rental - Rent a game server\n' +
|
||||
'• Technical Support - Get help with issues\n' +
|
||||
'• Billing - Payment and invoice questions'
|
||||
)
|
||||
.setFooter({ text: 'Aethex Sentinel Support System' });
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('ticket_rental')
|
||||
.setLabel('Rent Server')
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
.setEmoji('🖥️'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId('ticket_support')
|
||||
.setLabel('Technical Support')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('🔧'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId('ticket_billing')
|
||||
.setLabel('Billing')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('💰')
|
||||
);
|
||||
|
||||
await channel.send({ embeds: [embed], components: [row] });
|
||||
}
|
||||
}
|
||||
|
||||
export const ticketManager = new TicketManager();
|
||||
126
sentinel-bot/src/modules/dashboard/StatusUpdater.ts
Normal file
126
sentinel-bot/src/modules/dashboard/StatusUpdater.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { Client, VoiceChannel, ChannelType } from 'discord.js';
|
||||
import { prisma } from '../../core/client';
|
||||
import { config } from '../../core/config';
|
||||
|
||||
export class StatusUpdater {
|
||||
private client: Client;
|
||||
private intervalId: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(client: Client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.intervalId) return;
|
||||
|
||||
this.update();
|
||||
|
||||
this.intervalId = setInterval(() => {
|
||||
this.update();
|
||||
}, config.dashboard.updateIntervalMs);
|
||||
|
||||
console.log(`📊 Status updater started (interval: ${config.dashboard.updateIntervalMs}ms)`);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
console.log('📊 Status updater stopped');
|
||||
}
|
||||
}
|
||||
|
||||
async update(): Promise<void> {
|
||||
try {
|
||||
const guildIds = Object.values(config.guilds).filter(id => id);
|
||||
let totalMembers = 0;
|
||||
const guildStats: { id: string; name: string; members: number }[] = [];
|
||||
|
||||
for (const guildId of guildIds) {
|
||||
const guild = this.client.guilds.cache.get(guildId);
|
||||
if (guild) {
|
||||
totalMembers += guild.memberCount;
|
||||
guildStats.push({
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
members: guild.memberCount,
|
||||
});
|
||||
|
||||
await prisma.guildConfig.upsert({
|
||||
where: { id: guild.id },
|
||||
update: {
|
||||
name: guild.name,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
type: this.getGuildType(guild.id),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (config.dashboard.statusChannelId && config.guilds.hub) {
|
||||
await this.updateStatusChannel(totalMembers);
|
||||
}
|
||||
|
||||
console.log(`📊 Network status: ${totalMembers} total members across ${guildStats.length} guilds`);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to update status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateStatusChannel(totalMembers: number): Promise<void> {
|
||||
try {
|
||||
const hubGuild = this.client.guilds.cache.get(config.guilds.hub);
|
||||
if (!hubGuild) return;
|
||||
|
||||
const channel = hubGuild.channels.cache.get(config.dashboard.statusChannelId!);
|
||||
if (!channel || channel.type !== ChannelType.GuildVoice) return;
|
||||
|
||||
const voiceChannel = channel as VoiceChannel;
|
||||
const newName = `🟢 Network: ${totalMembers.toLocaleString()} Users`;
|
||||
|
||||
if (voiceChannel.name !== newName) {
|
||||
await voiceChannel.setName(newName);
|
||||
console.log(`📊 Updated status channel: ${newName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to update status channel:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private getGuildType(guildId: string): string {
|
||||
const entries = Object.entries(config.guilds);
|
||||
const found = entries.find(([, id]) => id === guildId);
|
||||
return found ? found[0] : 'unknown';
|
||||
}
|
||||
|
||||
async getNetworkStats(): Promise<{
|
||||
totalMembers: number;
|
||||
totalGuilds: number;
|
||||
guilds: { id: string; name: string; members: number; type: string }[];
|
||||
}> {
|
||||
const guildIds = Object.values(config.guilds).filter(id => id);
|
||||
const guilds: { id: string; name: string; members: number; type: string }[] = [];
|
||||
|
||||
for (const guildId of guildIds) {
|
||||
const guild = this.client.guilds.cache.get(guildId);
|
||||
if (guild) {
|
||||
guilds.push({
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
members: guild.memberCount,
|
||||
type: this.getGuildType(guild.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalMembers: guilds.reduce((sum, g) => sum + g.members, 0),
|
||||
totalGuilds: guilds.length,
|
||||
guilds,
|
||||
};
|
||||
}
|
||||
}
|
||||
124
sentinel-bot/src/modules/federation/FederationManager.ts
Normal file
124
sentinel-bot/src/modules/federation/FederationManager.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { Guild, GuildMember, Role } from 'discord.js';
|
||||
import { prisma } from '../../core/client';
|
||||
import { config } from '../../core/config';
|
||||
|
||||
interface RoleMapping {
|
||||
sourceGuild: string;
|
||||
sourceRole: string;
|
||||
targetGuild: string;
|
||||
targetRole: string;
|
||||
roleName: string;
|
||||
}
|
||||
|
||||
export class FederationManager {
|
||||
private roleMappings: RoleMapping[] = [];
|
||||
|
||||
async loadMappings(): Promise<void> {
|
||||
this.roleMappings = await prisma.roleMapping.findMany();
|
||||
console.log(`📋 Loaded ${this.roleMappings.length} role mappings`);
|
||||
}
|
||||
|
||||
async addMapping(mapping: Omit<RoleMapping, 'id'>): Promise<void> {
|
||||
await prisma.roleMapping.create({
|
||||
data: mapping,
|
||||
});
|
||||
await this.loadMappings();
|
||||
}
|
||||
|
||||
async syncRoleGrant(member: GuildMember, role: Role): Promise<void> {
|
||||
const sourceGuildId = member.guild.id;
|
||||
|
||||
const mappings = this.roleMappings.filter(
|
||||
m => m.sourceGuild === sourceGuildId && m.sourceRole === role.id
|
||||
);
|
||||
|
||||
if (mappings.length === 0) return;
|
||||
|
||||
console.log(`🔄 Syncing role "${role.name}" for ${member.user.tag} to ${mappings.length} guild(s)`);
|
||||
|
||||
for (const mapping of mappings) {
|
||||
try {
|
||||
const targetGuild = member.client.guilds.cache.get(mapping.targetGuild);
|
||||
if (!targetGuild) {
|
||||
console.warn(`⚠️ Target guild ${mapping.targetGuild} not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetMember = await targetGuild.members.fetch(member.id).catch(() => null);
|
||||
if (!targetMember) {
|
||||
console.warn(`⚠️ User ${member.user.tag} not in target guild ${targetGuild.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetRole = targetGuild.roles.cache.get(mapping.targetRole);
|
||||
if (!targetRole) {
|
||||
console.warn(`⚠️ Target role ${mapping.targetRole} not found in ${targetGuild.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!targetMember.roles.cache.has(targetRole.id)) {
|
||||
await targetMember.roles.add(targetRole, `Federation sync from ${member.guild.name}`);
|
||||
console.log(`✅ Granted "${targetRole.name}" to ${member.user.tag} in ${targetGuild.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to sync role to guild ${mapping.targetGuild}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
await this.updateUserRoles(member.id);
|
||||
}
|
||||
|
||||
async syncRoleRemove(member: GuildMember, role: Role): Promise<void> {
|
||||
const sourceGuildId = member.guild.id;
|
||||
|
||||
const mappings = this.roleMappings.filter(
|
||||
m => m.sourceGuild === sourceGuildId && m.sourceRole === role.id
|
||||
);
|
||||
|
||||
if (mappings.length === 0) return;
|
||||
|
||||
console.log(`🔄 Removing synced role "${role.name}" for ${member.user.tag} from ${mappings.length} guild(s)`);
|
||||
|
||||
for (const mapping of mappings) {
|
||||
try {
|
||||
const targetGuild = member.client.guilds.cache.get(mapping.targetGuild);
|
||||
if (!targetGuild) continue;
|
||||
|
||||
const targetMember = await targetGuild.members.fetch(member.id).catch(() => null);
|
||||
if (!targetMember) continue;
|
||||
|
||||
const targetRole = targetGuild.roles.cache.get(mapping.targetRole);
|
||||
if (!targetRole) continue;
|
||||
|
||||
if (targetMember.roles.cache.has(targetRole.id)) {
|
||||
await targetMember.roles.remove(targetRole, `Federation sync removal from ${member.guild.name}`);
|
||||
console.log(`✅ Removed "${targetRole.name}" from ${member.user.tag} in ${targetGuild.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to remove synced role:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
await this.updateUserRoles(member.id);
|
||||
}
|
||||
|
||||
private async updateUserRoles(userId: string): Promise<void> {
|
||||
const syncedRoles = this.roleMappings
|
||||
.filter(m => m.sourceGuild === config.guilds.hub)
|
||||
.map(m => m.roleName);
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { id: userId },
|
||||
update: { roles: syncedRoles },
|
||||
create: { id: userId, roles: syncedRoles },
|
||||
});
|
||||
}
|
||||
|
||||
getGuildType(guildId: string): string | null {
|
||||
const entries = Object.entries(config.guilds);
|
||||
const found = entries.find(([, id]) => id === guildId);
|
||||
return found ? found[0] : null;
|
||||
}
|
||||
}
|
||||
|
||||
export const federationManager = new FederationManager();
|
||||
131
sentinel-bot/src/modules/security/HeatSystem.ts
Normal file
131
sentinel-bot/src/modules/security/HeatSystem.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { Guild, GuildMember, PermissionFlagsBits } from 'discord.js';
|
||||
import { prisma } from '../../core/client';
|
||||
import { config } from '../../core/config';
|
||||
|
||||
type DangerousAction = 'CHANNEL_DELETE' | 'BAN_ADD' | 'KICK' | 'ROLE_DELETE' | 'WEBHOOK_DELETE';
|
||||
|
||||
interface HeatEvent {
|
||||
userId: string;
|
||||
action: DangerousAction;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export class HeatSystem {
|
||||
private heatCache: Map<string, HeatEvent[]> = new Map();
|
||||
|
||||
isWhitelisted(userId: string): boolean {
|
||||
return config.security.whitelistedUsers.includes(userId);
|
||||
}
|
||||
|
||||
async recordAction(userId: string, action: DangerousAction, guild: Guild): Promise<boolean> {
|
||||
if (this.isWhitelisted(userId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const event: HeatEvent = { userId, action, timestamp: now };
|
||||
|
||||
await prisma.heatEvent.create({
|
||||
data: { userId, action, timestamp: now },
|
||||
});
|
||||
|
||||
const events = this.heatCache.get(userId) || [];
|
||||
events.push(event);
|
||||
|
||||
const windowStart = new Date(now.getTime() - config.security.heatWindowMs);
|
||||
const recentEvents = events.filter(e => e.timestamp >= windowStart);
|
||||
this.heatCache.set(userId, recentEvents);
|
||||
|
||||
console.log(`🔥 Heat event: ${action} by ${userId} (${recentEvents.length}/${config.security.heatThreshold})`);
|
||||
|
||||
if (recentEvents.length >= config.security.heatThreshold) {
|
||||
console.log(`🚨 THRESHOLD EXCEEDED for ${userId}!`);
|
||||
await this.triggerLockdown(userId, guild, recentEvents);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async triggerLockdown(userId: string, guild: Guild, events: HeatEvent[]): Promise<void> {
|
||||
console.log(`🔒 LOCKDOWN INITIATED in ${guild.name}`);
|
||||
|
||||
try {
|
||||
const member = await guild.members.fetch(userId).catch(() => null);
|
||||
if (member && member.bannable) {
|
||||
await member.ban({
|
||||
reason: `[SENTINEL] Anti-nuke triggered: ${events.length} dangerous actions in ${config.security.heatWindowMs}ms`,
|
||||
deleteMessageSeconds: 0,
|
||||
});
|
||||
console.log(`🔨 Banned ${member.user.tag} for nuke attempt`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to ban attacker:', error);
|
||||
}
|
||||
|
||||
try {
|
||||
const everyoneRole = guild.roles.everyone;
|
||||
await everyoneRole.setPermissions(
|
||||
everyoneRole.permissions.remove([
|
||||
PermissionFlagsBits.SendMessages,
|
||||
PermissionFlagsBits.AddReactions,
|
||||
PermissionFlagsBits.CreatePublicThreads,
|
||||
PermissionFlagsBits.CreatePrivateThreads,
|
||||
]),
|
||||
'[SENTINEL] Lockdown mode activated'
|
||||
);
|
||||
console.log(`🔒 Locked down @everyone permissions`);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to modify @everyone permissions:', error);
|
||||
}
|
||||
|
||||
await this.sendLockdownAlert(guild, userId, events);
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { id: userId },
|
||||
update: { heatLevel: events.length },
|
||||
create: { id: userId, heatLevel: events.length },
|
||||
});
|
||||
}
|
||||
|
||||
private async sendLockdownAlert(guild: Guild, attackerId: string, events: HeatEvent[]): Promise<void> {
|
||||
const owner = await guild.fetchOwner().catch(() => null);
|
||||
if (!owner) return;
|
||||
|
||||
try {
|
||||
await owner.send({
|
||||
content: `🚨 **LOCKDOWN ALERT** 🚨\n\n` +
|
||||
`Server: **${guild.name}**\n` +
|
||||
`Attacker ID: \`${attackerId}\`\n` +
|
||||
`Actions detected: ${events.length}\n` +
|
||||
`Actions: ${events.map(e => e.action).join(', ')}\n\n` +
|
||||
`The attacker has been banned and @everyone permissions have been restricted.\n` +
|
||||
`Please review audit logs and restore permissions when safe.`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to DM server owner:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async unlockGuild(guild: Guild): Promise<void> {
|
||||
try {
|
||||
const everyoneRole = guild.roles.everyone;
|
||||
await everyoneRole.setPermissions(
|
||||
everyoneRole.permissions.add([
|
||||
PermissionFlagsBits.SendMessages,
|
||||
PermissionFlagsBits.AddReactions,
|
||||
]),
|
||||
'[SENTINEL] Lockdown lifted'
|
||||
);
|
||||
console.log(`🔓 Lifted lockdown in ${guild.name}`);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to unlock guild:', error);
|
||||
}
|
||||
}
|
||||
|
||||
clearHeat(userId: string): void {
|
||||
this.heatCache.delete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
export const heatSystem = new HeatSystem();
|
||||
21
sentinel-bot/tsconfig.json
Normal file
21
sentinel-bot/tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Loading…
Reference in a new issue