diff --git a/migrations/versions/16ce54fc6920_add_voteban_state.py b/migrations/versions/16ce54fc6920_add_voteban_state.py new file mode 100644 index 0000000..034791d --- /dev/null +++ b/migrations/versions/16ce54fc6920_add_voteban_state.py @@ -0,0 +1,65 @@ +"""add_voteban_state + +Revision ID: 16ce54fc6920 +Revises: 75925089facf +Create Date: 2026-05-03 20:59:51.492122 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '16ce54fc6920' +down_revision: Union[str, Sequence[str], None] = '75925089facf' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('votebanstate', + sa.Column('guild_id', sa.BigInteger(), nullable=False), + sa.Column('moderator_id', sa.BigInteger(), nullable=False), + sa.Column('user_id', sa.BigInteger(), nullable=False), + sa.Column('reason', sa.String(), nullable=False), + sa.Column('original_duration', sa.String(), nullable=False), + sa.Column('duration', sa.Integer(), nullable=False), + sa.Column('original_delete_messages_per', sa.String(), nullable=True), + sa.Column('delete_messages_per', sa.Integer(), nullable=True), + sa.Column('against_moderators_ids', sa.ARRAY(sa.BigInteger()), nullable=True), + sa.Column('for_moderators_ids', sa.ARRAY(sa.BigInteger()), nullable=True), + sa.Column('attachments_urls', sa.ARRAY(sa.String()), nullable=True), + sa.Column('state', sa.Enum('pending', 'approved', 'denied', name='votebanstateenum', native_enum=False), nullable=False), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_vote_ban_guild_user_state', 'votebanstate', ['guild_id', 'user_id', 'state'], unique=False) + op.create_index('uq_vote_ban_guild_user_pending', 'votebanstate', ['guild_id', 'user_id'], unique=True, postgresql_where=sa.text("state = 'pending'")) + op.drop_constraint(op.f('uq_guild_channel_role_section'), 'guildforumconfig', type_='unique') + op.create_unique_constraint(None, 'guildforumconfig', ['section_id']) + op.create_unique_constraint(None, 'guildforumconfig', ['role_id']) + op.create_unique_constraint(None, 'guildforumconfig', ['guild_id']) + op.create_unique_constraint(None, 'guildforumconfig', ['channel_id']) + op.create_unique_constraint(None, 'processedforumthread', ['thread_id']) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'processedforumthread', type_='unique') + op.drop_constraint(None, 'guildforumconfig', type_='unique') + op.drop_constraint(None, 'guildforumconfig', type_='unique') + op.drop_constraint(None, 'guildforumconfig', type_='unique') + op.drop_constraint(None, 'guildforumconfig', type_='unique') + op.create_unique_constraint(op.f('uq_guild_channel_role_section'), 'guildforumconfig', ['guild_id', 'channel_id', 'role_id', 'section_id'], postgresql_nulls_not_distinct=False) + op.drop_index('uq_vote_ban_guild_user_pending', table_name='votebanstate', postgresql_where=sa.text("state = 'pending'")) + op.drop_index('idx_vote_ban_guild_user_state', table_name='votebanstate') + op.drop_table('votebanstate') + # ### end Alembic commands ### diff --git a/src/infra/db/models/__init__.py b/src/infra/db/models/__init__.py index 1f341f0..9fff46d 100644 --- a/src/infra/db/models/__init__.py +++ b/src/infra/db/models/__init__.py @@ -31,6 +31,7 @@ from .ticket import Base, TicketState # noqa: F811 from .transfer_history import Base, TransferHistory # noqa: F811 from .user import Base, User # noqa: F811 +from .voteban import Base, VoteBanState # noqa: F811 __all__ = ( "Base", @@ -66,4 +67,5 @@ "TicketState", "TransferHistory", "User", + "VoteBanState", ) diff --git a/src/infra/db/models/_enums.py b/src/infra/db/models/_enums.py index 677a9ef..d554146 100644 --- a/src/infra/db/models/_enums.py +++ b/src/infra/db/models/_enums.py @@ -66,6 +66,12 @@ class NotifyStateEnum(Enum): TIMED_OUT = "timed_out" +class VoteBanStateEnum(Enum): + PENDING = "pending" + APPROVED = "approved" + DENIED = "denied" + + class ClanMemberRoleEnum(Enum): LEADER = "leader" DEPUTY = "deputy" diff --git a/src/infra/db/models/_mixins.py b/src/infra/db/models/_mixins.py index 1edf0b0..68548e1 100644 --- a/src/infra/db/models/_mixins.py +++ b/src/infra/db/models/_mixins.py @@ -12,5 +12,14 @@ class CreatedAtMixin: ) +class UpdatedAtMixin: + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=datetime.now, + onupdate=datetime.now, + ) + + class IdIntegerMixin: id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) diff --git a/src/infra/db/models/voteban.py b/src/infra/db/models/voteban.py new file mode 100644 index 0000000..0de6f10 --- /dev/null +++ b/src/infra/db/models/voteban.py @@ -0,0 +1,61 @@ +"""TicketState model for the Nightcore bot database.""" + +from sqlalchemy import ARRAY, BigInteger, Enum, Index, String +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import expression + +from src.infra.db.models._enums import VoteBanStateEnum +from src.infra.db.models._mixins import ( + CreatedAtMixin, + IdIntegerMixin, + UpdatedAtMixin, +) +from src.infra.db.models.base import Base + + +class VoteBanState(IdIntegerMixin, CreatedAtMixin, UpdatedAtMixin, Base): + guild_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + moderator_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + reason: Mapped[str] = mapped_column(nullable=False) + original_duration: Mapped[str] = mapped_column(nullable=False) + duration: Mapped[int] = mapped_column(nullable=False) + original_delete_messages_per: Mapped[str] = mapped_column(nullable=True) + delete_messages_per: Mapped[int] = mapped_column(nullable=True) + against_moderators_ids: Mapped[list[int]] = mapped_column( + ARRAY(BigInteger), nullable=True + ) + for_moderators_ids: Mapped[list[int]] = mapped_column( + ARRAY(BigInteger), nullable=True + ) + attachments_urls: Mapped[list[str]] = mapped_column( + ARRAY(String), nullable=True + ) + + state: Mapped[VoteBanStateEnum] = mapped_column( + Enum( + VoteBanStateEnum, + native_enum=False, + values_callable=lambda x: [e.value for e in x], # type: ignore + validate_strings=True, + ), + nullable=False, + default=VoteBanStateEnum.PENDING, + ) + + __table_args__ = ( + Index( + "uq_vote_ban_guild_user_pending", + "guild_id", + "user_id", + unique=True, + postgresql_where=expression.column("state") + == VoteBanStateEnum.PENDING.value, + ), + Index( + "idx_vote_ban_guild_user_state", + "guild_id", + "user_id", + "state", + ), + ) diff --git a/src/infra/db/operations.py b/src/infra/db/operations.py index 67067d0..0f7d81a 100644 --- a/src/infra/db/operations.py +++ b/src/infra/db/operations.py @@ -41,6 +41,7 @@ TicketState, TransferHistory, User, + VoteBanState, ) from src.infra.db.models._annot import ( ModerationStatsResultAnnot, @@ -634,6 +635,21 @@ async def get_latest_user_role_request( return res.scalar_one_or_none() +async def get_voteban_state( + session: AsyncSession, *, guild_id: int, user_id: int +) -> VoteBanState | None: + """Get latest voteban state for a user and lock row for update.""" + stmt = ( + select(VoteBanState) + .where(VoteBanState.user_id == user_id) + .order_by(VoteBanState.updated_at.desc().nulls_last()) + .limit(1) + .with_for_update() + ) + res = await session.execute(stmt) + return res.scalar_one_or_none() + + async def get_fraction_roles( session: AsyncSession, *, guild_id: int ) -> list[str]: diff --git a/src/nightcore/bot.py b/src/nightcore/bot.py index 4473e87..18efc6a 100644 --- a/src/nightcore/bot.py +++ b/src/nightcore/bot.py @@ -7,7 +7,7 @@ import discord from aiohttp import TCPConnector -from discord import app_commands +from discord import ClientUser, app_commands from discord.ext.commands import Bot # type: ignore from nightforo import Client as XenforoClient @@ -64,6 +64,8 @@ async def interaction_check( class Nightcore(Bot): + user: ClientUser # type: ignore + def __init__( self, *, @@ -74,6 +76,7 @@ def __init__( self.uow = uow self.apis = CustomAPICollection() self.images_cache = ImageCache() + self.config = config super().__init__( command_prefix=".", diff --git a/src/nightcore/events/interaction.py b/src/nightcore/events/interaction.py index 3233fd7..a6609fc 100644 --- a/src/nightcore/events/interaction.py +++ b/src/nightcore/events/interaction.py @@ -31,6 +31,9 @@ from src.nightcore.features.meta.components.v2.view.handlers.roleselector import ( # noqa: E501 handle_role_selector_select, ) +from src.nightcore.features.moderation.components.v2.view.handlers import ( + handle_voteban_button_callback, +) from src.nightcore.features.role_requests.components.v2 import ( SendRoleRequestView, ) @@ -86,9 +89,15 @@ async def on_interaction(interaction: Interaction["Nightcore"]) -> None: # type interaction=interaction, view=SendRoleRequestView, ) + case str() if custom_id.startswith("role_selector:"): await handle_role_selector_select(interaction=interaction) + case str() if custom_id.startswith("voteban:"): + await handle_voteban_button_callback( + interaction=interaction, custom_id=custom_id + ) + case str() if custom_id.startswith("casino:"): match custom_id: case "casino:roulette:multiplayer": diff --git a/src/nightcore/features/moderation/commands/ban.py b/src/nightcore/features/moderation/commands/ban.py index d5716e1..79a6ec4 100644 --- a/src/nightcore/features/moderation/commands/ban.py +++ b/src/nightcore/features/moderation/commands/ban.py @@ -28,6 +28,8 @@ ) from src.nightcore.services.config import specified_guild_config from src.nightcore.utils import ( + cast_guild, + cast_member, compare_top_roles, ensure_member_exists, ensure_messageable_channel_exists, @@ -281,24 +283,26 @@ async def _ban_request_callback( interaction: Interaction["Nightcore"], user: discord.Member ): """Callback for the ban request context menu.""" - guild = cast(Guild, interaction.guild) - client = interaction.client - # Ensure we have a guild Member object + guild = cast_guild(interaction.guild) + + bot = interaction.client + member = await ensure_member_exists(guild, user.id) + moderator = cast_member(interaction.user) if member is None: return await interaction.response.send_message( embed=ErrorEmbed( "Ошибка отправки запроса на бан", "Пользователь не найден на сервере.", - client.user.name, # type: ignore - client.user.display_avatar.url, # type: ignore + bot.user.name, + bot.user.display_avatar.url, ), ephemeral=True, ) async with specified_guild_config( - client, + bot, guild.id, GuildModerationConfig, ) as (guild_config, _): @@ -312,10 +316,10 @@ async def _ban_request_callback( ): raise FieldNotConfiguredError("канал запросов на бан") - if not (ban_access_roles := guild_config.ban_access_roles_ids): + if not guild_config.ban_access_roles_ids: raise FieldNotConfiguredError("доступ к бану") - ban_request_ping_role_id = guild_config.ban_request_ping_role_id + ping_role_id = guild_config.ban_request_ping_role_id has_moder_role = any( interaction.user.get_role(role_id) # type: ignore @@ -324,8 +328,8 @@ async def _ban_request_callback( if not has_moder_role: return await interaction.response.send_message( embed=MissingPermissionsEmbed( - client.user.name, # type: ignore - client.user.display_avatar.url, # type: ignore + bot.user.name, + bot.user.display_avatar.url, ), ephemeral=True, ) @@ -337,8 +341,8 @@ async def _ban_request_callback( return await interaction.response.send_message( embed=ValidationErrorEmbed( "Вы не можете забанить модераторов.", - client.user.name, # type: ignore - client.user.display_avatar.url, # type: ignore + bot.user.name, + bot.user.display_avatar.url, ), ephemeral=True, ) @@ -347,8 +351,8 @@ async def _ban_request_callback( return await interaction.response.send_message( embed=ValidationErrorEmbed( "Вы не можете забанить администраторов.", - client.user.name, # type: ignore - client.user.display_avatar.url, # type: ignore + bot.user.name, + bot.user.display_avatar.url, ), ephemeral=True, ) @@ -356,8 +360,8 @@ async def _ban_request_callback( if not guild.me.guild_permissions.ban_members: return await interaction.response.send_message( embed=MissingPermissionsEmbed( - client.user.name, # type: ignore - client.user.display_avatar.url, # type: ignore + bot.user.name, + bot.user.display_avatar.url, "У меня нет прав на бан участников.", ), ephemeral=True, @@ -367,8 +371,8 @@ async def _ban_request_callback( return await interaction.response.send_message( embed=ValidationErrorEmbed( "Вы не можете забанить меня.", - client.user.name, # type: ignore - client.user.display_avatar.url, # type: ignore + bot.user.name, + bot.user.display_avatar.url, ), ephemeral=True, ) @@ -376,22 +380,13 @@ async def _ban_request_callback( if not compare_top_roles(guild, member): return await interaction.response.send_message( embed=MissingPermissionsEmbed( - client.user.name, # type: ignore - client.user.display_avatar.url, # type: ignore + bot.user.name, + bot.user.display_avatar.url, "Я не могу забанить этого пользователя, потому что у него роль выше моей.", # noqa: E501 ), ephemeral=True, ) - role = None - if ban_request_ping_role_id: - role = guild.get_role(ban_request_ping_role_id) - if role is None: - try: - role = await guild.fetch_role(ban_request_ping_role_id) - except discord.NotFound: - role = None - channel = await ensure_messageable_channel_exists( guild, ban_request_channel_id ) @@ -400,19 +395,16 @@ async def _ban_request_callback( embed=ErrorEmbed( "Ошибка отправки запроса на бан", "Канал для отправки запросов на бан не найден.", - client.user.name, # type: ignore - client.user.display_avatar.url, # type: ignore + bot.user.name, + bot.user.display_avatar.url, ) ) modal = BanFormModal( - target=user, - moderator=interaction.user, # type: ignore - bot=client, - ping_role=role, - channel=channel, # type: ignore - ban_access_roles_ids=ban_access_roles, - moderation_access_roles_ids=moderation_access_roles, + user=member, + moderator=moderator, + voteban_channel=channel, # type: ignore + ping_role_id=ping_role_id, ) await interaction.response.send_modal(modal) diff --git a/src/nightcore/features/moderation/commands/voteban.py b/src/nightcore/features/moderation/commands/voteban.py index b2d3ff7..1975c7b 100644 --- a/src/nightcore/features/moderation/commands/voteban.py +++ b/src/nightcore/features/moderation/commands/voteban.py @@ -1,18 +1,15 @@ """Command to send a vote ban request.""" import logging -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING -from discord import ( - Guild, - User, - app_commands, -) +from discord import User, app_commands from discord.ext.commands import Cog # type: ignore from discord.interactions import Interaction +from sqlalchemy.exc import IntegrityError -from src.config.config import config -from src.infra.db.models import GuildModerationConfig +from src.infra.db.models import GuildModerationConfig, VoteBanState +from src.infra.db.models._enums import VoteBanStateEnum from src.nightcore.components.embed import ( EntityNotFoundEmbed, ErrorEmbed, @@ -29,10 +26,10 @@ ) from src.nightcore.services.config import specified_guild_config from src.nightcore.utils import ( + cast_guild, compare_top_roles, ensure_member_exists, ensure_messageable_channel_exists, - ensure_role_exists, has_any_role_from_sequence, ) from src.nightcore.utils.permissions import ( @@ -53,7 +50,7 @@ def __init__(self, bot: "Nightcore") -> None: @app_commands.command( # type: ignore name="voteban", - description="Отправить запрос на голосование по бану пользователя", + description="Отправить запрос на бан пользователя", ) @app_commands.guild_only() @app_commands.describe( @@ -65,7 +62,7 @@ def __init__(self, bot: "Nightcore") -> None: @check_required_permissions(PermissionsFlagEnum.MODERATION_ACCESS) # type: ignore async def voteban( self, - interaction: Interaction, + interaction: Interaction["Nightcore"], user: User, duration: str, reason: app_commands.Transform[ @@ -74,9 +71,7 @@ async def voteban( delete_messages_per: str | None = None, ): """Vote to ban a user on the server.""" - guild = cast(Guild, interaction.guild) - - ping_role = None + guild = cast_guild(interaction.guild) async with specified_guild_config( self.bot, @@ -85,7 +80,7 @@ async def voteban( ) as (guild_config, _): moderation_access_roles = guild_config.moderation_access_roles_ids - if not (ban_access_roles := guild_config.ban_access_roles_ids): + if not guild_config.ban_access_roles_ids: raise FieldNotConfiguredError("доступ к банам") if not ( @@ -96,9 +91,6 @@ async def voteban( ping_role_id = guild_config.ban_request_ping_role_id - if ping_role_id: - ping_role = await ensure_role_exists(guild, ping_role_id) - if guild.me == user: return await interaction.response.send_message( embed=ErrorEmbed( @@ -140,8 +132,8 @@ async def voteban( if not compare_top_roles(guild, member): return await interaction.response.send_message( embed=MissingPermissionsEmbed( - self.bot.user.name, # type: ignore - self.bot.user.display_avatar.url, # type: ignore + self.bot.user.name, + self.bot.user.display_avatar.url, "Я не могу забанить этого пользователя, потому что у него роль выше, чем у меня.", # noqa: E501 ), ephemeral=True, @@ -149,7 +141,7 @@ async def voteban( parsed_duration = parse_duration(duration) - if not parsed_duration: + if parsed_duration is None: return await interaction.response.send_message( embed=ValidationErrorEmbed( "Неверная продолжительность. Используйте s/m/h/d (например, 1h, 1d, 7d).", # noqa: E501 @@ -173,10 +165,13 @@ async def voteban( ), ) - if tmp_delete_messages_per > config.bot.DELETE_MESSAGES_SECONDS: + if ( + tmp_delete_messages_per + > self.bot.config.bot.DELETE_MESSAGES_SECONDS + ): return await interaction.response.send_message( embed=ValidationErrorEmbed( - f"Продолжительность удаления сообщений не может превышать {config.bot.DELETE_MESSAGES_SECONDS // 86400} дней.", # noqa: E501 + f"Продолжительность удаления сообщений не может превышать {self.bot.config.bot.DELETE_MESSAGES_SECONDS // 86400} дней.", # noqa: E501 self.bot.user.name, # type: ignore self.bot.user.display_avatar.url, # type: ignore ), @@ -198,26 +193,45 @@ async def voteban( ) await interaction.response.defer(thinking=True, ephemeral=True) + try: + async with self.bot.uow.start() as session: + votebanstate = VoteBanState( + guild_id=guild.id, + moderator_id=interaction.user.id, + user_id=user.id, + reason=reason, + original_duration=duration, + duration=parsed_duration, + original_delete_messages_per=delete_messages_per, + delete_messages_per=parsed_delete_messages_per_seconds, + state=VoteBanStateEnum.PENDING, + ) + + session.add(votebanstate) + await session.flush() + + except IntegrityError: + return await interaction.followup.send( + embed=ErrorEmbed( + "Ошибка отправки запроса на блокировку", + "Пользователь уже имеет активный запрос на блокировку.", + self.bot.user.name, # type: ignore + self.bot.user.display_avatar.url, # type: ignore + ) + ) + view = BanRequestViewV2( - author_id=interaction.user.id, - reason=reason, - target=user, bot=self.bot, - ping_role=ping_role, - original_duration=duration, - duration=parsed_duration, - original_delete_seconds=delete_messages_per, - delete_seconds=parsed_delete_messages_per_seconds, - ban_access_roles_ids=ban_access_roles, - moderation_access_roles_ids=cast( - list[int], moderation_access_roles - ), - ) + moderator_id=votebanstate.moderator_id, + user=user, + reason=votebanstate.reason, + original_duration=votebanstate.original_duration, + original_delete_messages_per=votebanstate.original_delete_messages_per, + ping_role_id=ping_role_id, + ).create_component() try: - message = await channel.send( # type: ignore - view=view - ) + message = await channel.send(view=view) # type: ignore await interaction.followup.send( embed=SuccessMoveEmbed( @@ -245,7 +259,7 @@ async def voteban( ) logger.info( - "[ban_request_submit] - invoked user=%s guild=%s target=%s duration=%s reason=%s delete_messages_for_last=%s", # noqa: E501 + "[voteban_submit] - invoked user=%s guild=%s target=%s duration=%s reason=%s delete_messages_for_last=%s", # noqa: E501 interaction.user.id, channel.guild.id, user.id, diff --git a/src/nightcore/features/moderation/components/modal/ban_request.py b/src/nightcore/features/moderation/components/modal/ban_request.py index 492668b..56c5efc 100644 --- a/src/nightcore/features/moderation/components/modal/ban_request.py +++ b/src/nightcore/features/moderation/components/modal/ban_request.py @@ -4,9 +4,12 @@ from typing import TYPE_CHECKING import discord +from discord import Member +from discord.interactions import Interaction from discord.ui import FileUpload, Label, Modal, TextInput +from sqlalchemy.exc import IntegrityError -from src.config.config import config +from src.infra.db.models import VoteBanState from src.nightcore.components.embed import ( ErrorEmbed, SuccessMoveEmbed, @@ -14,6 +17,7 @@ ) from src.nightcore.features.moderation.components.v2 import BanRequestViewV2 from src.nightcore.utils.content import is_image_url +from src.nightcore.utils.object import cast_guild from src.nightcore.utils.time_utils import parse_duration if TYPE_CHECKING: @@ -38,7 +42,7 @@ class BanFormModal(Modal, title="Отправить запрос на бан"): max_length=1000, ) - delete_messages_for_last = TextInput["BanFormModal"]( + delete_messages_per = TextInput["BanFormModal"]( label="Удалить сообщения за последние", style=discord.TextStyle.short, placeholder="Пример: 1m, 1h, 1d, 7d", @@ -53,70 +57,69 @@ class BanFormModal(Modal, title="Отправить запрос на бан"): def __init__( self, - target: discord.Member, - moderator: discord.Member, - bot: "Nightcore", - channel: discord.TextChannel | discord.Thread, - ban_access_roles_ids: list[int], - moderation_access_roles_ids: list[int], - ping_role: discord.Role | None = None, + moderator: Member, + user: Member, + voteban_channel: discord.TextChannel | discord.Thread, + ping_role_id: int | None = None, ): super().__init__() - self.target = target + self.user = user self.moderator = moderator - self.bot = bot - self.ping_role = ping_role - self.channel = channel - self.ban_access_roles_ids = ban_access_roles_ids - self.moderation_access_roles_ids: list[int] = ( - moderation_access_roles_ids - ) + self.ping_role_id = ping_role_id + self.voteban_channel = voteban_channel - async def on_submit(self, interaction: discord.Interaction) -> None: + async def on_submit(self, interaction: Interaction["Nightcore"]) -> None: # pyright: ignore[reportIncompatibleMethodOverride] """Handles the submission of the ban form modal.""" + + bot = interaction.client + guild = cast_guild(interaction.guild) + reason = self.reason.value await interaction.response.defer(ephemeral=True, thinking=True) - duration_seconds = parse_duration(self.duration.value) - if duration_seconds is None: + parsed_duration = parse_duration(self.duration.value) + if parsed_duration is None: return await interaction.followup.send( embed=ValidationErrorEmbed( "Invalid duration format. Use s/m/h/d (e.g., 30m, 2h, 3d).", # noqa: E501 - self.bot.user.name, # type: ignore - self.bot.user.display_avatar.url, # type: ignore + bot.user.name, + bot.user.display_avatar.url, ), ephemeral=True, ) - delete_seconds = 0 - original_delete_seconds = "" + parsed_delete_messages_per = None + original_delete_messages_per = "" - if self.delete_messages_for_last.value: - delete_seconds = parse_duration( - self.delete_messages_for_last.value + if self.delete_messages_per.value: + parsed_delete_messages_per = parse_duration( + self.delete_messages_per.value ) - if delete_seconds is None: + if parsed_delete_messages_per is None: return await interaction.followup.send( embed=ValidationErrorEmbed( "Неверная продолжительность. Используйте s/m/h/d до 7d (например, 30m, 2h, 3d).", # noqa: E501 - self.bot.user.name, # type: ignore - self.bot.user.display_avatar.url, # type: ignore + bot.user.name, + bot.user.display_avatar.url, ), ) - if delete_seconds > config.bot.DELETE_MESSAGES_SECONDS: + if ( + parsed_delete_messages_per + > bot.config.bot.DELETE_MESSAGES_SECONDS + ): return await interaction.followup.send( embed=ValidationErrorEmbed( - f"Продолжительность удаления сообщений не может превышать {config.bot.DELETE_MESSAGES_SECONDS // 86400} дней.", # noqa: E501 - self.bot.user.name, # type: ignore - self.bot.user.display_avatar.url, # type: ignore + f"Продолжительность удаления сообщений не может превышать {bot.config.bot.DELETE_MESSAGES_SECONDS // 86400} дней.", # noqa: E501 + bot.user.name, + bot.user.display_avatar.url, ), ) - original_delete_seconds = self.delete_messages_for_last.value + original_delete_messages_per = self.delete_messages_per.value - attachment_urls: list[str] = [] + attachments_urls: list[str] = [] if attachments := self.attachment.component.values: # type: ignore (list[Attachment]) for attachment in attachments: # type: ignore content_type = getattr(attachment, "content_type", None) # type: ignore @@ -130,57 +133,79 @@ async def on_submit(self, interaction: discord.Interaction) -> None: if not is_image_url(attachment.url): # type: ignore continue - attachment_urls.append(attachment.url) # type: ignore + attachments_urls.append(attachment.url) # type: ignore + + try: + async with bot.uow.start() as session: + votebanstate = VoteBanState( + guild_id=guild.id, + moderator_id=self.moderator.id, + user_id=self.user.id, + reason=reason, + original_duration=self.duration.value, + duration=parsed_duration, + original_delete_messages_per=self.delete_messages_per.value, + delete_messages_per=original_delete_messages_per, + attachments_urls=attachments_urls, + ) + + session.add(votebanstate) + await session.flush() + + except IntegrityError: + return await interaction.followup.send( + embed=ErrorEmbed( + "Ошибка отправки запроса на блокировку", + "Пользователь уже имеет активный запрос на блокировку.", + self.bot.user.name, # type: ignore + self.bot.user.display_avatar.url, # type: ignore + ) + ) view = BanRequestViewV2( - author_id=self.moderator.id, - reason=reason, - target=self.target, - bot=self.bot, - ping_role=self.ping_role, - original_duration=self.duration.value, - duration=duration_seconds, - original_delete_seconds=original_delete_seconds, - delete_seconds=delete_seconds, - ban_access_roles_ids=self.ban_access_roles_ids, - moderation_access_roles_ids=self.moderation_access_roles_ids, - attachments=attachment_urls, - ) + bot=bot, + moderator_id=votebanstate.moderator_id, + user=self.user, + reason=votebanstate.reason, + original_duration=votebanstate.original_duration, + original_delete_messages_per=votebanstate.original_delete_messages_per, + ping_role_id=self.ping_role_id, + ).create_component() try: - message = await self.channel.send(view=view) + message = await self.voteban_channel.send(view=view) await interaction.followup.send( embed=SuccessMoveEmbed( "Запрос на бан отправлен", - f"Ваш {message.jump_url} для {self.target.mention} был успешно отправлен.", # noqa: E501 - self.bot.user.name, # type: ignore - self.bot.user.display_avatar.url, # type: ignore - ) + f"Ваш [запрос на бан]({message.jump_url}) для {self.user.mention} было успешно отправлено.", # noqa: E501 # type: ignore + bot.user.name, + bot.user.display_avatar.url, + ), ) except Exception as e: logger.exception( "Failed to send message in guild %s to channel %s: %s", - self.channel.guild.id, - self.channel.id, + guild.id, + self.voteban_channel.id, e, ) return await interaction.followup.send( embed=ErrorEmbed( "Запрос на бан не удался", "Не удалось отправить сообщение с запросом на бан.", - self.bot.user.name, # type: ignore - self.bot.user.display_avatar.url, # type: ignore + bot.user.name, + bot.user.display_avatar.url, ) ) logger.info( "[ban_request_submit] - invoked user=%s guild=%s target=%s duration=%s reason=%s delete_messages_for_last=%s", # noqa: E501 self.moderator.id, - self.channel.guild.id, - self.target.id, + guild.id, + self.user.id, self.duration.value, reason, - self.delete_messages_for_last.value, + self.delete_messages_per.value, ) diff --git a/src/nightcore/features/moderation/components/v2/view/ban_request.py b/src/nightcore/features/moderation/components/v2/view/ban_request.py index d0dff13..640223a 100644 --- a/src/nightcore/features/moderation/components/v2/view/ban_request.py +++ b/src/nightcore/features/moderation/components/v2/view/ban_request.py @@ -1,346 +1,146 @@ """View for paginating infractions.""" -import asyncio -import logging from datetime import UTC, datetime -from typing import TYPE_CHECKING, Self, cast +from typing import TYPE_CHECKING, Self -import discord from discord import ( ButtonStyle, - Guild, + Color, MediaGalleryItem, - Member, - Role, - User, ) -from discord.interactions import Interaction from discord.ui import ( ActionRow, Button, Container, - Item, LayoutView, MediaGallery, Section, Separator, TextDisplay, Thumbnail, - button, ) if TYPE_CHECKING: - from src.nightcore.bot import Nightcore - - -from src.nightcore.components.embed import ( - MissingPermissionsEmbed, -) -from src.nightcore.features.moderation.events.dto import UserBannedEventData -from src.nightcore.features.moderation.utils.punish_notify import ( - send_punish_dm_message, -) -from src.nightcore.utils import discord_ts, has_any_role_from_sequence - -logger = logging.getLogger(__name__) - - -class ActionButtons(ActionRow["BanRequestViewV2"]): - def __init__(self): - super().__init__() - - async def interaction_check( - self, - interaction: Interaction, - ) -> bool: - """Ensure that only users with ban access roles can interact with the view.""" # noqa: E501 - view = cast(BanRequestViewV2, self.view) - user = cast(Member, interaction.user) - has_moder_role = has_any_role_from_sequence( - user, view.moderation_access_roles_ids - ) - - # TODO: get info about ban request from components after bot restarting - if not has_moder_role: - await interaction.response.send_message( - embed=MissingPermissionsEmbed( - view.bot.user.name, # type: ignore - view.bot.user.display_avatar.url, # type: ignore - ), - ephemeral=True, - ) - return False - return True - - @button( - style=ButtonStyle.green, - emoji="<:check:1442915033079353404>", - label="Одобрить", - custom_id="ban_request:approve", - ) - async def approve( - self, interaction: Interaction, button: Button["BanRequestViewV2"] - ): - """Approve the ban request.""" - view = cast(BanRequestViewV2, self.view) - moderator = cast(Member, interaction.user) - guild = cast(Guild, interaction.guild) - - async with view.approve_lock: - if view.is_closed: - return await interaction.response.send_message( - "Этот запрос на бан уже был закрыт.", - ephemeral=True, - ) - - if moderator.id in view.in_favor: - return await interaction.response.send_message( - "Вы уже проголосовали.", - ephemeral=True, - ) - - view.in_favor.append(moderator.id) - view.in_favor_moderators_text += f"- <@{moderator.id}>\n" - - if not ( - len(view.in_favor) >= 4 - or has_any_role_from_sequence( - moderator, - view.ban_access_roles_ids, - ) - ): - return await interaction.response.edit_message( - view=view.make_component() - ) - - view.is_closed = True - view.accent_color = discord.Color.green() - - await interaction.response.defer() - - await interaction.edit_original_response( - view=view.make_component(disabled=True) - ) - - target = view.target - - data = UserBannedEventData( - mode="dm", - guild_name=guild.name, - guild_id=guild.id, - category="ban", - moderator_id=view.author_id, - user=target, - reason=view.reason, - created_at=discord.utils.utcnow().astimezone(tz=UTC), - duration=view.duration, - original_duration=view.original_duration, - delete_messages_per=view.original_delete_seconds, - ) - - try: - await send_punish_dm_message( - view.bot, guild_name=guild.name, event_data=data - ) - except Exception as e: - logger.exception( - "[event] - Failed to send ban DM to user=%s guild=%s: %s", - target.id, - guild.id, - e, - ) - - try: - view.bot.dispatch("user_banned", data=data) - except Exception as e: - logger.exception( - "[event] - Failed to dispatch user_banned event: %s", e - ) - - try: - await guild.ban( - target, - reason=view.reason, - delete_message_seconds=view.delete_seconds, - ) - except (discord.Forbidden, discord.HTTPException) as e: - logger.exception( - "Failed to ban user=%s guild=%s: %s", target.id, guild.id, e - ) - - view.accent_color = discord.Color.red() - await interaction.edit_original_response( - view=view.make_component(disabled=True) - ) - await interaction.followup.send( - embed=MissingPermissionsEmbed( - view.bot.user.name, # type: ignore - view.bot.user.display_avatar.url, # type: ignore - ), - ephemeral=True, - ) - - return + from discord import Member, User - return - - @button( - style=ButtonStyle.red, - emoji="<:failed:1442915170320912506>", - label="Отклонить", - custom_id="ban_request:deny", - ) - async def deny( - self, interaction: Interaction, button: Button["BanRequestViewV2"] - ): - """Deny the ban request.""" - view = cast("BanRequestViewV2", self.view) - user = cast(Member, interaction.user) - - if view.is_closed: - return await interaction.response.send_message( - "Этот запрос на бан уже был закрыт.", - ephemeral=True, - ) - - if user.id in view.against: - return await interaction.response.send_message( - "Вы уже проголосовали.", - ephemeral=True, - ) - - view.against.append(user.id) - - view.against_moderators_text += f"- <@{user.id}>\n" - - has_ban_role = has_any_role_from_sequence( - user, view.ban_access_roles_ids - ) - if ( - has_ban_role - or interaction.user.id == view.author_id - or len(view.against) >= 4 - ): - view.accent_color = discord.Color.red() - - view.is_closed = True - - await interaction.response.defer() - - return await interaction.edit_original_response( - view=view.make_component(disabled=True), - ) - - await interaction.response.defer() + from src.nightcore.bot import Nightcore - await interaction.edit_original_response( - view=view.make_component(), - ) +from src.infra.db.models._enums import VoteBanStateEnum +from src.nightcore.utils import discord_ts class BanRequestViewV2(LayoutView): def __init__( self, - author_id: int, - reason: str, - target: User | Member, bot: "Nightcore", - duration: int, + moderator_id: int, + user: "User | Member", + reason: str, original_duration: str, - delete_seconds: int, - moderation_access_roles_ids: list[int], - ban_access_roles_ids: list[int], - original_delete_seconds: str | None = None, - ping_role: Role | None = None, + original_delete_messages_per: str | None = None, + ping_role_id: int | None = None, + state: "VoteBanStateEnum" = VoteBanStateEnum.PENDING, attachments: list[str] | None = None, + for_moderators_ids: list[int] | None = None, + against_moderators_ids: list[int] | None = None, ): super().__init__(timeout=None) - self.author_id = author_id - self.reason = reason - self.target = target + self.bot = bot - self.duration = duration + self.moderator_id = moderator_id + self.user = user + self.reason = reason self.original_duration = original_duration - self.delete_seconds = delete_seconds - self.original_delete_seconds = original_delete_seconds - self.moderation_access_roles_ids = moderation_access_roles_ids - self.ban_access_roles_ids = ban_access_roles_ids - self.ping_role = ping_role + self.original_delete_messages_per = original_delete_messages_per + self.ping_role_id = ping_role_id + self.state = state self.attachments = attachments + self.for_moderators_ids = for_moderators_ids + self.against_moderators_ids = against_moderators_ids - self.actions: ActionButtons | None = None - self.header_text: TextDisplay[BanRequestViewV2] - self.footer_text: TextDisplay[BanRequestViewV2] - self.in_favor_moderators_text = "" - self.against_moderators_text = "" - self.accent_color = discord.Color.yellow() + def create_component(self) -> Self: + """Create the view component based on the current state.""" - self.approve_lock = asyncio.Lock() - self.in_favor: list[int] = [] - self.against: list[int] = [] - self.is_closed: bool = False + accent_color: Color | None = None - self.make_component() + match self.state: + case VoteBanStateEnum.APPROVED: + accent_color = Color.green() + case VoteBanStateEnum.DENIED: + accent_color = Color.red() + case _: + accent_color = None - def disable_buttons(self): - """Disable all buttons in the view.""" - if self.actions: - for item in self.actions.children: - if isinstance(item, Button): - item.disabled = True # type: ignore - - def make_component(self, *, disabled: bool = False) -> Self: - """Create the layout view component.""" - - self.clear_items() - - container = Container[Self](accent_color=self.accent_color) + container = Container[Self](accent_color=accent_color) pre_header_text = "## Запрос на бан" - if self.ping_role: - pre_header_text += f" {self.ping_role.mention}" + if self.ping_role_id: + pre_header_text += f" <@&{self.ping_role_id}>" container.add_item(TextDisplay[Self](pre_header_text)) container.add_item(Separator[Self]()) - self.header_text = TextDisplay[Self]( - f"**Пользователь:** {self.target.mention} (`{self.target.id}`)\n" - f"**Модератор:** <@{self.author_id}>\n" - f"**Причина: `{self.reason}`**\n" - f"**Длительность: `{self.original_duration}`**\n" - f"**Удалить сообщения за последние: `{self.original_delete_seconds if self.original_delete_seconds else 'N/A'}`**\n" # noqa: E501 - ) - header_section = Section[Self]( - self.header_text, - accessory=Thumbnail(self.target.display_avatar.url), + container.add_item( + Section[Self]( + TextDisplay[Self]( + f"**Пользователь:** {self.user.mention} (`{self.user.id}`)\n" # noqa: E501 + f"**Модератор:** <@{self.moderator_id}>\n" + f"**Причина: {self.reason}**\n" + f"**Длительность: {self.original_duration}**\n" + f"**Удалить сообщения за последние: `{self.original_delete_messages_per if self.original_delete_messages_per else 'N/A'}`**\n" # noqa: E501 + ), + accessory=Thumbnail(self.user.display_avatar.url), + ) ) - container.add_item(header_section) container.add_item(Separator[Self]()) container.add_item(TextDisplay[Self]("### За:")) - if self.in_favor_moderators_text: + if self.for_moderators_ids: container.add_item( - TextDisplay[Self](self.in_favor_moderators_text) + TextDisplay[Self]( + "".join(f"- <@{id}>\n" for id in self.for_moderators_ids) + ) ) + container.add_item(TextDisplay[Self]("### Против:")) - if self.against_moderators_text: - container.add_item(TextDisplay[Self](self.against_moderators_text)) + if self.against_moderators_ids: + container.add_item( + TextDisplay[Self]( + "".join( + f"- <@{id}>\n" for id in self.against_moderators_ids + ) + ) + ) container.add_item(Separator[Self]()) - # Main page text if self.attachments: container.add_item(TextDisplay[Self]("### Вложения:")) container.add_item( MediaGallery[Self]( *[MediaGalleryItem(url) for url in self.attachments] ) - ) # type: ignore + ) container.add_item(Separator[Self]()) - # Action buttons - self.actions = ActionButtons() - container.add_item(self.actions) + container.add_item( + ActionRow[Self]( + Button( + style=ButtonStyle.green, + emoji="<:check:1442915033079353404>", + label="Одобрить", + custom_id=f"voteban:{self.user.id}:approve", + disabled=self.state != VoteBanStateEnum.PENDING, + ), + Button( + style=ButtonStyle.red, + emoji="<:failed:1442915170320912506>", + label="Отклонить", + custom_id=f"voteban:{self.user.id}:deny", + disabled=self.state != VoteBanStateEnum.PENDING, + ), + ) + ) container.add_item(Separator[Self]()) # Footer @@ -348,26 +148,10 @@ def make_component(self, *, disabled: bool = False) -> Self: container.add_item( TextDisplay["BanRequestViewV2"]( - f"-# Powered by {self.bot.user.name} in {discord_ts(now)}" # type: ignore + f"-# Powered by {self.bot.user.name} in {discord_ts(now)}" ) ) - if disabled: - self.disable_buttons() - self.add_item(container) return self - - async def on_timeout(self): - """Disable all buttons when the view times out.""" - - def walk(item: Item[Self]): # type: ignore - if hasattr(item, "children"): - for c in item.children: # type: ignore - yield from walk(c) # type: ignore - yield item - - for comp in walk(self): # type: ignore - if isinstance(comp, Button): - comp.disabled = True diff --git a/src/nightcore/features/moderation/components/v2/view/handlers/__init__.py b/src/nightcore/features/moderation/components/v2/view/handlers/__init__.py new file mode 100644 index 0000000..e8910d9 --- /dev/null +++ b/src/nightcore/features/moderation/components/v2/view/handlers/__init__.py @@ -0,0 +1,3 @@ +from .ban_request import handle_voteban_button_callback + +__all__ = ("handle_voteban_button_callback",) diff --git a/src/nightcore/features/moderation/components/v2/view/handlers/ban_request.py b/src/nightcore/features/moderation/components/v2/view/handlers/ban_request.py new file mode 100644 index 0000000..8da1b4c --- /dev/null +++ b/src/nightcore/features/moderation/components/v2/view/handlers/ban_request.py @@ -0,0 +1,390 @@ +"""Handlers for ban request buttons.""" + +import logging +from datetime import UTC +from typing import TYPE_CHECKING + +import discord +from discord.interactions import Interaction +from sqlalchemy.orm import attributes + +from src.infra.db.models import GuildModerationConfig +from src.infra.db.models._enums import VoteBanStateEnum +from src.infra.db.operations import ( + get_specified_guild_config, + get_voteban_state, +) +from src.nightcore.components.embed import ErrorEmbed, MissingPermissionsEmbed +from src.nightcore.exceptions import ConfigMissingError +from src.nightcore.features.moderation.components.v2.view import ( + BanRequestViewV2, +) +from src.nightcore.features.moderation.events.dto import UserBannedEventData +from src.nightcore.features.moderation.utils.punish_notify import ( + send_punish_dm_message, +) +from src.nightcore.utils import ensure_member_exists +from src.nightcore.utils.object import ( + cast_guild, + cast_member, + has_any_role_from_sequence, +) + +if TYPE_CHECKING: + from src.nightcore.bot import Nightcore + +logger = logging.getLogger(__name__) + + +async def handle_voteban_button_callback( + interaction: Interaction["Nightcore"], custom_id: str +): + """Handle the voteban button callback interaction.""" + + bot = interaction.client + guild = cast_guild(interaction.guild) + moderator = cast_member(interaction.user) + + try: + _, user_id, action = custom_id.split(":") + except ValueError: + return await interaction.response.send_message( + embed=ErrorEmbed( + title="Ошибка взаимодействия", + description="Некорректный формат custom_id для кнопки голосования за бан.", # noqa: E501 + footer_text=bot.user.name, + footer_icon_url=bot.user.display_avatar.url, + ), + ephemeral=True, + ) + + try: + user_id = int(user_id) + except ValueError: + return await interaction.response.send_message( + embed=ErrorEmbed( + title="Ошибка взаимодействия", + description="Некорректный ID пользователя в custom_id для кнопки голосования за бан.", # noqa: E501 + footer_text=bot.user.name, + footer_icon_url=bot.user.display_avatar.url, + ), + ephemeral=True, + ) + + async with bot.uow.start() as session: + guild_config = await get_specified_guild_config( + session=session, + config_type=GuildModerationConfig, + guild_id=guild.id, + ) + if guild_config is None: + raise ConfigMissingError(guild.id) + + moderation_access_roles = guild_config.moderation_access_roles_ids + ban_access_roles = guild_config.ban_access_roles_ids + + ping_role_id = guild_config.ban_request_ping_role_id + + has_moder_role = has_any_role_from_sequence( + moderator, moderation_access_roles + ) + + if not has_moder_role: + return await interaction.response.send_message( + embed=MissingPermissionsEmbed( + footer_text=bot.user.name, + footer_icon_url=bot.user.display_avatar.url, + ), + ephemeral=True, + ) + + match action: + case "approve": + await handle_voteban_approve_button_callback( + interaction=interaction, + user_id=user_id, + ban_access_roles=ban_access_roles, + ping_role_id=ping_role_id, + ) + case "reject": + await handle_voteban_reject_button_callback( + interaction=interaction, + user_id=user_id, + ban_access_roles=ban_access_roles, + ping_role_id=ping_role_id, + ) + + case _: + return await interaction.response.send_message( + embed=ErrorEmbed( + title="Ошибка взаимодействия", + description="Некорректное действие в custom_id для кнопки голосования за бан.", # noqa: E501 + footer_text=bot.user.name, + footer_icon_url=bot.user.display_avatar.url, + ), + ephemeral=True, + ) + + +async def handle_voteban_approve_button_callback( + interaction: Interaction["Nightcore"], + user_id: int, + ban_access_roles: list[int] | None, + ping_role_id: int | None, +): + """Handle the voteban approve button callback interaction.""" + + await interaction.response.defer(ephemeral=True) + + bot = interaction.client + guild = cast_guild(interaction.guild) + moderator = cast_member(interaction.user) + user = cast_member(await ensure_member_exists(guild, user_id)) + + outcome = "" + async with bot.uow.start() as session: + votebanstate = await get_voteban_state( + session, guild_id=guild.id, user_id=user_id + ) + + if votebanstate is None: + outcome = "not_found" + else: + if votebanstate.state != VoteBanStateEnum.PENDING: + outcome = "already_concluded" + else: + if moderator.id in votebanstate.for_moderators_ids: + outcome = "already_voted" + else: + votebanstate.for_moderators_ids.append(moderator.id) + attributes.flag_modified( + votebanstate, "for_moderators_ids" + ) + + if not ( + len(votebanstate.for_moderators_ids) >= 4 + or has_any_role_from_sequence( + moderator, ban_access_roles + ) + ): + outcome = "success_stay_pending" + else: + votebanstate.state = VoteBanStateEnum.APPROVED + outcome = "success_approved" + + if outcome == "not_found": + return await interaction.followup.send( + embed=ErrorEmbed( + title="Ошибка голосования", + description="Состояние голосования за бан не найдено.", + footer_text=bot.user.name, + footer_icon_url=bot.user.display_avatar.url, + ), + ephemeral=True, + ) + + if outcome == "already_concluded": + return await interaction.followup.send( + embed=ErrorEmbed( + title="Ошибка голосования", + description="Голосование за бан уже было завершено.", + footer_text=bot.user.name, + footer_icon_url=bot.user.display_avatar.url, + ), + ephemeral=True, + ) + + if outcome == "already_voted": + return await interaction.followup.send( + embed=ErrorEmbed( + title="Ошибка голосования", + description="Вы уже голосовали в этом голосовании за бан.", + footer_text=bot.user.name, + footer_icon_url=bot.user.display_avatar.url, + ), + ephemeral=True, + ) + + view = BanRequestViewV2( + bot=bot, + moderator_id=votebanstate.moderator_id, # type: ignore + user=user, + reason=votebanstate.reason, # type: ignore + original_duration=votebanstate.original_duration, # type: ignore + original_delete_messages_per=votebanstate.original_delete_messages_per, # type: ignore + for_moderators_ids=votebanstate.for_moderators_ids, # type: ignore + against_moderators_ids=votebanstate.against_moderators_ids, # type: ignore + attachments=votebanstate.attachments_urls, # type: ignore + ping_role_id=ping_role_id, + ) + + if outcome == "success_stay_pending": + return await interaction.response.edit_message( + view=view.create_component() + ) + + if outcome == "success_approved": + view.state = VoteBanStateEnum.APPROVED + + await interaction.edit_original_response(view=view.create_component()) + + data = UserBannedEventData( + mode="dm", + guild_name=guild.name, + guild_id=guild.id, + category="ban", + moderator_id=moderator.id, + user=user, + reason=view.reason, + created_at=discord.utils.utcnow().astimezone(tz=UTC), + duration=votebanstate.duration, # type: ignore + original_duration=view.original_duration, + delete_messages_per=votebanstate.original_delete_messages_per, # type: ignore + ) + + try: + await send_punish_dm_message( + view.bot, guild_name=guild.name, event_data=data + ) + except Exception as e: + logger.exception( + "[event] - Failed to send ban DM to user=%s guild=%s: %s", + user.id, + guild.id, + e, + ) + + try: + view.bot.dispatch("user_banned", data=data) + except Exception as e: + logger.exception( + "[event] - Failed to dispatch user_banned event: %s", e + ) + + try: + await guild.ban( + user, + reason=view.reason, + delete_message_seconds=votebanstate.delete_messages_per, # type: ignore + ) + except (discord.Forbidden, discord.HTTPException) as e: + logger.exception( + "Failed to ban user=%s guild=%s: %s", user.id, guild.id, e + ) + + return await interaction.followup.send( + embed=MissingPermissionsEmbed( + view.bot.user.name, # type: ignore + view.bot.user.display_avatar.url, # type: ignore + ), + ephemeral=True, + ) + + return + + +async def handle_voteban_reject_button_callback( + interaction: Interaction["Nightcore"], + user_id: int, + ban_access_roles: list[int] | None, + ping_role_id: int | None, +): + """Handle the voteban reject button callback interaction.""" + await interaction.response.defer(ephemeral=True) + + bot = interaction.client + guild = cast_guild(interaction.guild) + moderator = cast_member(interaction.user) + user = cast_member(await ensure_member_exists(guild, user_id)) + + outcome = "" + async with bot.uow.start() as session: + votebanstate = await get_voteban_state( + session, guild_id=guild.id, user_id=user_id + ) + + if votebanstate is None: + outcome = "not_found" + else: + if votebanstate.state != VoteBanStateEnum.PENDING: + outcome = "already_concluded" + else: + against_ids = votebanstate.against_moderators_ids or [] + + if moderator.id in against_ids: + outcome = "already_voted" + else: + against_ids.append(moderator.id) + votebanstate.against_moderators_ids = against_ids + attributes.flag_modified( + votebanstate, "against_moderators_ids" + ) + + if not ( + len(against_ids) >= 4 + or has_any_role_from_sequence( + moderator, ban_access_roles + ) + or moderator.id == votebanstate.moderator_id + ): + outcome = "success_stay_pending" + else: + votebanstate.state = VoteBanStateEnum.DENIED + outcome = "success_denied" + + if outcome == "not_found": + return await interaction.followup.send( + embed=ErrorEmbed( + title="Ошибка голосования", + description="Состояние голосования за бан не найдено.", + footer_text=bot.user.name, + footer_icon_url=bot.user.display_avatar.url, + ), + ephemeral=True, + ) + + if outcome == "already_concluded": + return await interaction.followup.send( + embed=ErrorEmbed( + title="Ошибка голосования", + description="Голосование за бан уже было завершено.", + footer_text=bot.user.name, + footer_icon_url=bot.user.display_avatar.url, + ), + ephemeral=True, + ) + + if outcome == "already_voted": + return await interaction.followup.send( + embed=ErrorEmbed( + title="Ошибка голосования", + description="Вы уже голосовали в этом голосовании за бан.", + footer_text=bot.user.name, + footer_icon_url=bot.user.display_avatar.url, + ), + ephemeral=True, + ) + + view = BanRequestViewV2( + bot=bot, + moderator_id=votebanstate.moderator_id, # type: ignore + user=user, + reason=votebanstate.reason, # type: ignore + original_duration=votebanstate.original_duration, # type: ignore + original_delete_messages_per=votebanstate.original_delete_messages_per, # type: ignore + for_moderators_ids=votebanstate.for_moderators_ids, # type: ignore + against_moderators_ids=votebanstate.against_moderators_ids, # type: ignore + attachments=votebanstate.attachments_urls, # type: ignore + ping_role_id=ping_role_id, + ) + + if outcome == "success_stay_pending": + return await interaction.response.edit_message( + view=view.create_component() + ) + + if outcome == "success_denied": + view.state = VoteBanStateEnum.DENIED + return await interaction.edit_original_response( + view=view.create_component() + ) diff --git a/src/nightcore/setup.py b/src/nightcore/setup.py index 2c8a896..5d1251e 100644 --- a/src/nightcore/setup.py +++ b/src/nightcore/setup.py @@ -133,9 +133,9 @@ def create_bot(uow: UnitOfWork) -> Nightcore: "src.nightcore.tasks.temp_role", "src.nightcore.tasks.temp_multiplier", # special events / valentine day - "src.nightcore.features.special_events.valentine", + # "src.nightcore.features.special_events.valentine", # special events / valentine day events - "src.nightcore.features.special_events.valentine.events.valentine_send", + # "src.nightcore.features.special_events.valentine.events.valentine_send", # noqa: E501 ] return Nightcore( diff --git a/src/nightcore/utils/__init__.py b/src/nightcore/utils/__init__.py index 6421648..63fcb27 100644 --- a/src/nightcore/utils/__init__.py +++ b/src/nightcore/utils/__init__.py @@ -1,4 +1,6 @@ from .object import ( + cast_guild, + cast_member, channel_type, compare_top_roles, ensure_channel_is_messageable, @@ -18,6 +20,8 @@ from .time_utils import discord_ts, format_voice_time __all__ = ( + "cast_guild", + "cast_member", "channel_type", "compare_top_roles", "discord_ts", diff --git a/src/nightcore/utils/object.py b/src/nightcore/utils/object.py index ab0bd89..5ee073d 100644 --- a/src/nightcore/utils/object.py +++ b/src/nightcore/utils/object.py @@ -3,7 +3,7 @@ import logging from collections.abc import Sequence from enum import Enum -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from discord import ( CategoryChannel, @@ -336,3 +336,15 @@ def compare_top_roles(guild: Guild, entity: User | Member | Role) -> bool: role_position = entity.position return bot_top_role > role_position + + +def cast_guild(value: Guild | None) -> Guild: + """Cast a Guild object, ensuring it's not None.""" + + return cast(Guild, value) + + +def cast_member(value: Member | User | None) -> Member: + """Cast a Member object, ensuring it's not None.""" + + return cast(Member, value)