Skip to content
Open
6 changes: 6 additions & 0 deletions .test.env
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@ SENTRY_DSN=

# Channels
CHANNEL_SR_MOD=1127695218900993410
CHANNEL_MINOR_REVIEW=1437472925720280084
CHANNEL_VERIFY_LOGS=1012769518828339331
CHANNEL_BOT_COMMANDS=1276953350848588101
CHANNEL_SPOILER=2769521890099371011
CHANNEL_BOT_LOGS=1105517088266788925

# Roles
ROLE_VERIFIED=1333333333333333337
ROLE_VERIFIED_MINOR=1281517925395733615

ROLE_BIZCTF2022=7629466241011276950
ROLE_NOAH_GANG=6706800691011276950
Expand Down Expand Up @@ -98,3 +100,7 @@ SLACK_FEEDBACK_WEBHOOK="https://hook.slack.com/sdfsdfsf"

#Feedback Webhook
JIRA_WEBHOOK="https://automation.atlassian.com/sdfsdfsf"

# Parental Consent
PARENTAL_CONSENT_CHECK_URL="https://hackthebox.com/"
PARENTAL_CONSENT_SECRET="supersecretykey"
2 changes: 1 addition & 1 deletion CONTRIBUTORS
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ TheWeeknd <98106526+ToxicBiohazard@users.noreply.github.com>
Dimosthenis Schizas <dimos@hackthebox.eu>
makelarisjr <8687447+makelarisjr@users.noreply.github.com>
Jelle Janssens <janssensjelle@users.noreply.github.com>
Ryan Gordon <ry4n@hackthebox.eu>
Ryan Gordon <ry4n@hackthebox.eu>
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
Create Date: 2023-05-09 13:28:06.763604

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

from alembic import op

# revision identifiers, used by Alembic.
revision = '05c1d218bb76'
down_revision = '6948a2436536'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""add minor_report and minor_review_reviewer tables

Revision ID: 82ea695d0a65
Revises: 4fc1c39216c9
Create Date: 2026-02-16 12:31:57.651377

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

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


def upgrade() -> None:
op.create_table(
"minor_report",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", mysql.BIGINT(display_width=18), nullable=False),
sa.Column("reporter_id", mysql.BIGINT(display_width=18), nullable=False),
sa.Column("suspected_age", sa.Integer(), nullable=False),
sa.Column("evidence", mysql.TEXT(), nullable=False),
sa.Column("report_message_id", mysql.BIGINT(display_width=20), nullable=False),
sa.Column("status", mysql.VARCHAR(length=32), nullable=False, server_default="pending"),
sa.Column("reviewer_id", mysql.BIGINT(display_width=18), nullable=True),
sa.Column("created_at", mysql.TIMESTAMP(), nullable=False),
sa.Column("updated_at", mysql.TIMESTAMP(), nullable=False),
sa.Column("associated_ban_id", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"minor_review_reviewer",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", mysql.BIGINT(display_width=18), nullable=False),
sa.Column("added_by", mysql.BIGINT(display_width=18), nullable=True),
sa.Column("created_at", mysql.TIMESTAMP(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id"),
)


def downgrade() -> None:
op.drop_table("minor_review_reviewer")
op.drop_table("minor_report")
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
Create Date: 2023-05-09 13:34:17.055796

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

from alembic import op

# revision identifiers, used by Alembic.
revision = 'a5f283a4cfde'
down_revision = '05c1d218bb76'
Expand Down
2 changes: 1 addition & 1 deletion contributors.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ TheWeeknd <98106526+ToxicBiohazard@users.noreply.github.com>

EOF

git log --format='%aN <%aE>' | sort -uf >> "$file"
git log --format='%aN <%aE>' | sort -uf >> "$file"
25 changes: 20 additions & 5 deletions src/bot.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,33 @@
import logging
import socket
from typing import TypeVar

import discord
from aiohttp import AsyncResolver, ClientSession, TCPConnector
from discord import (
ApplicationContext, Cog, DiscordException, Embed, Forbidden, Guild, HTTPException, Member, NotFound, User,
ApplicationContext,
Cog,
DiscordException,
Embed,
Forbidden,
Guild,
HTTPException,
Member,
NotFound,
User,
)
from discord.ext.commands import Bot as DiscordBot
from discord.ext.commands import (
CommandNotFound, CommandOnCooldown, DefaultHelpCommand, MissingAnyRole, MissingPermissions,
MissingRequiredArgument, NoPrivateMessage, UserInputError,
CommandNotFound,
CommandOnCooldown,
DefaultHelpCommand,
MissingAnyRole,
MissingPermissions,
MissingRequiredArgument,
NoPrivateMessage,
UserInputError,
)
from sqlalchemy.exc import NoResultFound
from typing import TypeVar

from src import trace_config
from src.core import constants, settings
Expand Down Expand Up @@ -50,7 +65,7 @@ async def on_ready(self) -> None:
if self.http_session is None:
logger.debug("Starting the HTTP session")
self.http_session = ClientSession(
connector=TCPConnector(resolver=AsyncResolver(), family=socket.AF_INET),
connector=TCPConnector(resolver=AsyncResolver(), family=socket.AF_INET),
trace_configs=[trace_config]
)

Expand Down
2 changes: 1 addition & 1 deletion src/cmds/automation/auto_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def __init__(self, bot: Bot):

async def process_reverification(self, member: Member | User) -> None:
"""Re-verifation process for a member.

TODO: Reimplement once it's possible to fetch link state from the HTB Account.
"""
raise VerificationError("Not implemented")
Expand Down
87 changes: 84 additions & 3 deletions src/cmds/automation/scheduled_tasks.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import asyncio
import logging
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone

from discord import Member
from discord.ext import commands, tasks
from sqlalchemy import select

from src import settings
from src.bot import Bot
from src.database.models import Ban, Mute
from src.database.models import Ban, MinorReport, Mute
from src.database.session import AsyncSessionLocal
from src.helpers.ban import unban_member, unmute_member
from src.helpers.minor_verification import (
APPROVED,
CONSENT_VERIFIED,
assign_minor_role,
years_until_18,
)
from src.helpers.schedule import schedule

logger = logging.getLogger(__name__)
Expand All @@ -28,7 +35,7 @@ async def all_tasks(self) -> None:
logger.debug("Gathering scheduled tasks...")
await self.auto_unban()
await self.auto_unmute()
# await asyncio.gather(self.auto_unmute())
await self.auto_remove_minor_role()
logger.debug("Scheduling completed.")

async def auto_unban(self) -> None:
Expand Down Expand Up @@ -99,6 +106,80 @@ async def auto_unmute(self) -> None:

await asyncio.gather(*unmute_tasks)

async def auto_remove_minor_role(self) -> None:
"""Remove minor role from users who have reached 18 based on report data."""
logger.debug("Checking for minor roles to remove based on age.")
now = datetime.now(timezone.utc)

async with AsyncSessionLocal() as session:
result = await session.scalars(
select(MinorReport).filter(MinorReport.status.in_([APPROVED, CONSENT_VERIFIED]))
)
reports = result.all()

for guild_id in settings.guild_ids:
guild = self.bot.get_guild(guild_id)
if not guild:
logger.warning(f"Unable to find guild with ID {guild_id} for minor role cleanup.")
continue

for report in reports:
# Compute approximate 18th birthday based on suspected age at report time.
created_at = report.created_at
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
years = years_until_18(report.suspected_age)
expires_at = created_at + timedelta(days=365 * years)
if now < expires_at:
continue

member: Member | None = await self.bot.get_member_or_user(guild, report.user_id)
if not member:
continue

role_id = settings.roles.VERIFIED_MINOR
role = guild.get_role(role_id)
if not role or role not in member.roles:
continue

logger.info(
"Removing minor role from user %s (%s) because they have reached 18.",
member,
member.id,
)
try:
await member.remove_roles(role, atomic=True)
except Exception as exc:
logger.warning(
"Failed to remove minor role from %s (%s): %s", member, member.id, exc
)

@commands.Cog.listener()
async def on_member_join(self, member: Member) -> None:
"""Assign minor role on rejoin if consent is verified and they are still under 18."""
async with AsyncSessionLocal() as session:
result = await session.scalars(
select(MinorReport).filter(
MinorReport.user_id == member.id,
MinorReport.status == CONSENT_VERIFIED,
)
)
report = result.first()

if not report:
return

now = datetime.now(timezone.utc)
created_at = report.created_at
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
years = years_until_18(report.suspected_age)
expires_at = created_at + timedelta(days=365 * years)
if now >= expires_at:
return

await assign_minor_role(member, member.guild)


def setup(bot: Bot) -> None:
"""Load the `ScheduledTasks` cog."""
Expand Down
Loading