Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions alembic/versions/402666fa2440_align_db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Align DB

Revision ID: 402666fa2440
Revises: 4fc1c39216c9
Create Date: 2026-04-03 17:49:17.151261

"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql

# revision identifiers, used by Alembic.
revision = '402666fa2440'
down_revision = '4fc1c39216c9'
branch_labels = None
depends_on = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('ban', 'unban_time',
existing_type=mysql.BIGINT(display_width=18),
type_=mysql.BIGINT(display_width=11, unsigned=True),
existing_nullable=False)
op.drop_index(op.f('ix_ctf_id'), table_name='ctf')
op.alter_column('htb_discord_link', 'account_identifier',
existing_type=mysql.VARCHAR(length=255),
nullable=False)
op.alter_column('htb_discord_link', 'discord_user_id',
existing_type=mysql.VARCHAR(length=42),
type_=mysql.BIGINT(display_width=18),
nullable=False)
op.alter_column('htb_discord_link', 'htb_user_id',
existing_type=mysql.VARCHAR(length=255),
type_=mysql.BIGINT(),
nullable=False)
op.alter_column('mute', 'unmute_time',
existing_type=mysql.BIGINT(display_width=18),
type_=mysql.BIGINT(display_width=11, unsigned=True),
existing_nullable=False)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('mute', 'unmute_time',
existing_type=mysql.BIGINT(display_width=11, unsigned=True),
type_=mysql.BIGINT(display_width=18),
existing_nullable=False)
op.alter_column('htb_discord_link', 'htb_user_id',
existing_type=mysql.BIGINT(),
type_=mysql.VARCHAR(length=255),
nullable=True)
op.alter_column('htb_discord_link', 'discord_user_id',
existing_type=mysql.BIGINT(display_width=18),
type_=mysql.VARCHAR(length=42),
nullable=True)
op.alter_column('htb_discord_link', 'account_identifier',
existing_type=mysql.VARCHAR(length=255),
nullable=True)
op.create_index(op.f('ix_ctf_id'), 'ctf', ['id'], unique=False)
op.alter_column('ban', 'unban_time',
existing_type=mysql.BIGINT(display_width=11, unsigned=True),
type_=mysql.BIGINT(display_width=18),
existing_nullable=False)
# ### end Alembic commands ###
39 changes: 39 additions & 0 deletions alembic/versions/9aa21aede2ec_add_dynamic_role_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Add dynamic_role table

Revision ID: 9aa21aede2ec
Revises: 402666fa2440
Create Date: 2026-04-03 17:52:08.412419

"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql

# revision identifiers, used by Alembic.
revision = '9aa21aede2ec'
down_revision = '402666fa2440'
branch_labels = None
depends_on = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('dynamic_role',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('key', sa.String(length=64), nullable=False),
sa.Column('discord_role_id', mysql.BIGINT(unsigned=True), nullable=False),
sa.Column('category', sa.Enum('RANK', 'SEASON', 'SUBSCRIPTION_LABS', 'SUBSCRIPTION_ACADEMY', 'CREATOR', 'POSITION', 'ACADEMY_CERT', 'JOINABLE', name='rolecategory'), nullable=False),
sa.Column('display_name', sa.String(length=128), nullable=False),
sa.Column('description', sa.String(length=256), nullable=True),
sa.Column('cert_full_name', sa.String(length=128), nullable=True),
sa.Column('cert_integer_id', sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('key', 'category', name='uq_dynamic_role_key_category')
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('dynamic_role')
# ### end Alembic commands ###
31 changes: 29 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,35 @@
version: '3.8'

services:
db:
image: mariadb:10.11.2
environment:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: hackster
MYSQL_USER: hackster
MYSQL_PASSWORD: hackster
ports:
- "3306:3306"
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mariadb-admin -uroot -prootpassword ping || exit 1"]
start_period: 30s
interval: 10s
timeout: 5s
retries: 3

bot:
build: .
env_file:
- .env
depends_on:
db:
condition: service_healthy
environment:
MYSQL_HOST: db
MYSQL_PORT: 3306
MYSQL_DATABASE: hackster
MYSQL_USER: hackster
MYSQL_PASSWORD: hackster

volumes:
db_data:
176 changes: 176 additions & 0 deletions scripts/seed_dynamic_roles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"""
One-time seed script to populate the dynamic_role table from environment variables.

Usage:
ENV_PATH=.env python -m scripts.seed_dynamic_roles
# or for test env:
python -m scripts.seed_dynamic_roles (defaults to .test.env)
"""

import asyncio
import logging
import os
import sys

from dotenv import dotenv_values
from sqlalchemy.dialects.mysql import insert

# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from src.database.models.dynamic_role import DynamicRole, RoleCategory # noqa: E402
from src.database.session import AsyncSessionLocal # noqa: E402

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


# (env_var_suffix, category, key, display_name, extra_kwargs)
SEED_DATA = [
# Ranks
("OMNISCIENT", RoleCategory.RANK, "Omniscient", "Omniscient", {}),
("GURU", RoleCategory.RANK, "Guru", "Guru", {}),
("ELITE_HACKER", RoleCategory.RANK, "Elite Hacker", "Elite Hacker", {}),
("PRO_HACKER", RoleCategory.RANK, "Pro Hacker", "Pro Hacker", {}),
("HACKER", RoleCategory.RANK, "Hacker", "Hacker", {}),
("SCRIPT_KIDDIE", RoleCategory.RANK, "Script Kiddie", "Script Kiddie", {}),
("NOOB", RoleCategory.RANK, "Noob", "Noob", {}),
# Subscriptions - Labs
("VIP", RoleCategory.SUBSCRIPTION_LABS, "vip", "VIP", {}),
("VIP_PLUS", RoleCategory.SUBSCRIPTION_LABS, "dedivip", "VIP+", {}),
# Subscriptions - Academy
("SILVER_ANNUAL", RoleCategory.SUBSCRIPTION_ACADEMY, "Silver Annual", "Silver Annual", {}),
("GOLD_ANNUAL", RoleCategory.SUBSCRIPTION_ACADEMY, "Gold Annual", "Gold Annual", {}),
# Creators
("BOX_CREATOR", RoleCategory.CREATOR, "Box Creator", "Box Creator", {}),
("CHALLENGE_CREATOR", RoleCategory.CREATOR, "Challenge Creator", "Challenge Creator", {}),
("SHERLOCK_CREATOR", RoleCategory.CREATOR, "Sherlock Creator", "Sherlock Creator", {}),
# Positions
("RANK_ONE", RoleCategory.POSITION, "1", "Top 1", {}),
("RANK_TEN", RoleCategory.POSITION, "10", "Top 10", {}),
# Seasons
("SEASON_HOLO", RoleCategory.SEASON, "Holo", "Holo", {}),
("SEASON_PLATINUM", RoleCategory.SEASON, "Platinum", "Platinum", {}),
("SEASON_RUBY", RoleCategory.SEASON, "Ruby", "Ruby", {}),
("SEASON_SILVER", RoleCategory.SEASON, "Silver", "Silver", {}),
("SEASON_BRONZE", RoleCategory.SEASON, "Bronze", "Bronze", {}),
# Academy Certs (with cert_full_name and cert_integer_id)
("ACADEMY_CWES", RoleCategory.ACADEMY_CERT, "CWES", "Certified Web Exploitation Specialist", {
"cert_full_name": "HTB Certified Web Exploitation Specialist",
"cert_integer_id": 2,
}),
("ACADEMY_CPTS", RoleCategory.ACADEMY_CERT, "CPTS", "Certified Penetration Testing Specialist", {
"cert_full_name": "HTB Certified Penetration Testing Specialist",
"cert_integer_id": 3,
}),
("ACADEMY_CDSA", RoleCategory.ACADEMY_CERT, "CDSA", "Certified Defensive Security Analyst", {
"cert_full_name": "HTB Certified Defensive Security Analyst",
"cert_integer_id": 4,
}),
("ACADEMY_CWEE", RoleCategory.ACADEMY_CERT, "CWEE", "Certified Web Exploitation Expert", {
"cert_full_name": "HTB Certified Web Exploitation Expert",
"cert_integer_id": 5,
}),
("ACADEMY_CAPE", RoleCategory.ACADEMY_CERT, "CAPE", "Certified Active Directory Pentesting Expert", {
"cert_full_name": "HTB Certified Active Directory Pentesting Expert",
"cert_integer_id": 6,
}),
("ACADEMY_CJCA", RoleCategory.ACADEMY_CERT, "CJCA", "Certified Junior Cybersecurity Associate", {
"cert_full_name": "HTB Certified Junior Cybersecurity Associate",
"cert_integer_id": 7,
}),
("ACADEMY_CWPE", RoleCategory.ACADEMY_CERT, "CWPE", "Certified Wi-Fi Pentesting Expert", {
"cert_full_name": "HTB Certified Wi-Fi Pentesting Expert",
"cert_integer_id": 8,
}),
]

# Joinable roles: multiple display names can share the same env var / discord_role_id.
# Format: (env_var_suffix, key, display_name, description)
JOINABLE_SEED_DATA = [
("UNICTF2022", "Cyber Apocalypse", "Cyber Apocalypse", "Pinged for CTF Announcements"),
("UNICTF2022", "Business CTF", "Business CTF", "Pinged for CTF Announcements"),
("UNICTF2022", "University CTF", "University CTF", "Pinged for CTF Announcements"),
("NOAH_GANG", "Noah Gang", "Noah Gang", "Get pinged when Fugl posts pictures of his cute bird"),
("BUDDY_GANG", "Buddy Gang", "Buddy Gang", "Get pinged when Legacyy posts pictures of his cute dog"),
("RED_TEAM", "Red Team", "Red Team", "Red team fans. Also gives access to the Red and Blue team channels"),
("BLUE_TEAM", "Blue Team", "Blue Team", "Blue team fans. Also gives access to the Red and Blue team channels"),
]


async def _upsert_role(session, values: dict) -> None:
"""Insert or update a dynamic role using MariaDB upsert."""
stmt = insert(DynamicRole).values(values)
# On duplicate key, update all fields except the unique key (key, category)
stmt = stmt.on_duplicate_key_update(
discord_role_id=stmt.inserted.discord_role_id,
display_name=stmt.inserted.display_name,
description=stmt.inserted.description,
cert_full_name=stmt.inserted.cert_full_name,
cert_integer_id=stmt.inserted.cert_integer_id,
)
await session.execute(stmt)


async def seed(env_file: str) -> None:
env_values = dotenv_values(env_file)
logger.info(f"Loaded env from {env_file} ({len(env_values)} values)")

upserted = 0
skipped = 0

async with AsyncSessionLocal() as session:
# Seed standard dynamic roles
for env_suffix, category, key, display_name, extra in SEED_DATA:
env_var = f"ROLE_{env_suffix}"
role_id_str = env_values.get(env_var)
if not role_id_str:
logger.warning(f"Skipping {env_var}: not found in {env_file}")
skipped += 1
continue

values = {
"key": key,
"discord_role_id": int(role_id_str),
"category": category,
"display_name": display_name,
"description": None,
"cert_full_name": extra.get("cert_full_name"),
"cert_integer_id": extra.get("cert_integer_id"),
}

await _upsert_role(session, values)
upserted += 1
logger.info(f" {category.value}/{key} = {role_id_str}")

# Seed joinable roles
for env_suffix, key, display_name, description in JOINABLE_SEED_DATA:
env_var = f"ROLE_{env_suffix}"
role_id_str = env_values.get(env_var)
if not role_id_str:
logger.warning(f"Skipping joinable {env_var}/{key}: not found in {env_file}")
skipped += 1
continue

values = {
"key": key,
"discord_role_id": int(role_id_str),
"category": RoleCategory.JOINABLE,
"display_name": display_name,
"description": description,
"cert_full_name": None,
"cert_integer_id": None,
}

await _upsert_role(session, values)
upserted += 1
logger.info(f" joinable/{key} = {role_id_str}")

await session.commit()

logger.info(f"Seeding complete: {upserted} upserted, {skipped} skipped")


if __name__ == "__main__":
env_file = os.environ.get("ENV_PATH", ".test.env")
asyncio.run(seed(env_file))
7 changes: 7 additions & 0 deletions src/__main__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import asyncio
import logging

from src.bot import bot
from src.core import settings
from src.services.role_manager import RoleManager
from src.utils.extensions import walk_extensions
from src.webhooks.server import serve

logger = logging.getLogger(__name__)

# Load dynamic roles from DB BEFORE starting servers (eliminates race condition).
role_manager = RoleManager(fallback_roles=settings.roles)
asyncio.get_event_loop().run_until_complete(role_manager.load())
bot.role_manager = role_manager

# Load all cogs extensions.
for ext in walk_extensions():
if ext == "src.cmds.automation.scheduled_tasks":
Expand Down
8 changes: 8 additions & 0 deletions src/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def __init__(self, mock: bool = False, **kwargs):
mock (bool): Whether to mock the client or not.
"""
super().__init__(**kwargs)
self.role_manager = None
if not mock:
logger.debug("HTTP session will be initialized in an asynchronous context")
self.http_session = None
Expand All @@ -75,6 +76,13 @@ async def on_ready(self) -> None:
self.send_log(devlog_msg, colour=constants.colours.bright_green)
)

# Refresh dynamic roles cache on (re)connect
if self.role_manager:
try:
await self.role_manager.reload()
except Exception:
logger.warning("Failed to reload dynamic roles on reconnect, keeping previous cache", exc_info=True)

logger.info(f"Started bot as {name}")
print("Loading ScheduledTasks cog...")
try:
Expand Down
Loading
Loading