modified: bot.py
This commit is contained in:
498
bot.py
498
bot.py
@@ -2874,11 +2874,12 @@ async def on_message(message):
|
|||||||
guild_id = message.guild.id
|
guild_id = message.guild.id
|
||||||
member = message.author # Das Member-Objekt für Datenaktualisierung
|
member = message.author # Das Member-Objekt für Datenaktualisierung
|
||||||
|
|
||||||
# ── Honeypot check ──────────────────────────────────────────────────────────
|
# ── Honeypot check ─────────────────────────────────────────────────────────────
|
||||||
|
guild_id = message.guild.id
|
||||||
guild_settings = get_guild_settings(guild_id)
|
guild_settings = get_guild_settings(guild_id)
|
||||||
|
|
||||||
if guild_settings.get("honeypot_enabled") and guild_settings.get("honeypot_channel_id"):
|
if guild_settings.get("honeypot_enabled") and guild_settings.get("honeypot_channel_id"):
|
||||||
if message.channel.id == int(guild_settings["honeypot_channel_id"]):
|
if message.channel.id == int(guild_settings["honeypot_channel_id"]):
|
||||||
# Build ignore-role set
|
|
||||||
ignore_role_ids = set()
|
ignore_role_ids = set()
|
||||||
raw_ignore = guild_settings.get("honeypot_ignore_roles")
|
raw_ignore = guild_settings.get("honeypot_ignore_roles")
|
||||||
if raw_ignore:
|
if raw_ignore:
|
||||||
@@ -2887,9 +2888,12 @@ async def on_message(message):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
member = message.author if isinstance(message.author, discord.Member) else message.guild.get_member(message.author.id)
|
||||||
|
if member is None or member.bot:
|
||||||
|
return
|
||||||
|
|
||||||
member_role_ids = {role.id for role in member.roles}
|
member_role_ids = {role.id for role in member.roles}
|
||||||
if not (ignore_role_ids & member_role_ids):
|
if not (ignore_role_ids & member_role_ids):
|
||||||
# Delete the honeypot message
|
|
||||||
try:
|
try:
|
||||||
await message.delete()
|
await message.delete()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -2899,67 +2903,36 @@ async def on_message(message):
|
|||||||
acc_age_min = int(guild_settings.get("honeypot_acc_age_min") or 30)
|
acc_age_min = int(guild_settings.get("honeypot_acc_age_min") or 30)
|
||||||
preserve = guild_settings.get("honeypot_preserve_old_accounts", False)
|
preserve = guild_settings.get("honeypot_preserve_old_accounts", False)
|
||||||
|
|
||||||
# Determine: old-account mute vs ban
|
|
||||||
if preserve and member.joined_at:
|
if preserve and member.joined_at:
|
||||||
now_aware = datetime.now(member.joined_at.tzinfo)
|
now_aware = datetime.now(member.joined_at.tzinfo)
|
||||||
days_on_server = (now_aware - member.joined_at).days
|
days_on_server = (now_aware - member.joined_at).days
|
||||||
is_old_account = days_on_server >= acc_age_min
|
is_old_account = days_on_server >= acc_age_min
|
||||||
else:
|
else:
|
||||||
|
days_on_server = None
|
||||||
is_old_account = False
|
is_old_account = False
|
||||||
|
|
||||||
if is_old_account:
|
if is_old_account:
|
||||||
# Apply 1-year (365 days) mute via the mute system
|
try:
|
||||||
honeypot_role = None
|
result = await apply_mute_action(
|
||||||
hp_role_id = guild_settings.get("honeypot_get_role")
|
guild=message.guild,
|
||||||
if hp_role_id:
|
member=member,
|
||||||
try:
|
moderator=client.user,
|
||||||
honeypot_role = message.guild.get_role(int(hp_role_id))
|
duration_seconds=365 * 86400,
|
||||||
except Exception:
|
duration_label="365d",
|
||||||
pass
|
reason="Honeypot: wrote in honeypot channel (protected old member)",
|
||||||
if honeypot_role is None:
|
source_channel=message.channel,
|
||||||
honeypot_role = await get_or_create_mute_role(message.guild, guild_settings)
|
message_data=None,
|
||||||
|
message_id=message.id,
|
||||||
if honeypot_role:
|
remove_existing_roles=False,
|
||||||
try:
|
save_removed_roles=False,
|
||||||
await member.add_roles(
|
increment_mute_count=True
|
||||||
honeypot_role,
|
)
|
||||||
reason="Honeypot: wrote in honeypot channel (account protected as old member)"
|
action_taken = "mute"
|
||||||
)
|
honeypot_mute_id = result["mute_id"]
|
||||||
# Persist mute record for 365 days
|
except discord.Forbidden:
|
||||||
start_time = datetime.now()
|
logger.warning(f"Honeypot: no permission to mute {member.id} in guild {guild_id}")
|
||||||
end_time = start_time + timedelta(days=365)
|
except Exception as e:
|
||||||
process_data = {
|
logger.error(f"Honeypot mute failed for {member.id} in guild {guild_id}: {e}")
|
||||||
"user_id": member.id,
|
|
||||||
"guild_id": guild_id,
|
|
||||||
"channel_id": message.channel.id,
|
|
||||||
"reason": "Honeypot trigger",
|
|
||||||
"moderator_id": client.user.id,
|
|
||||||
"mute_role_id": honeypot_role.id
|
|
||||||
}
|
|
||||||
process_uuid = create_active_process(
|
|
||||||
process_type="mute",
|
|
||||||
guild_id=guild_id,
|
|
||||||
channel_id=message.channel.id,
|
|
||||||
user_id=member.id,
|
|
||||||
target_id=member.id,
|
|
||||||
end_time=end_time,
|
|
||||||
data=process_data
|
|
||||||
)
|
|
||||||
await save_mute_to_database(
|
|
||||||
user_id=member.id,
|
|
||||||
guild_id=guild_id,
|
|
||||||
moderator_id=client.user.id,
|
|
||||||
reason="Honeypot: wrote in honeypot channel (account protected as old member)",
|
|
||||||
duration="365d",
|
|
||||||
start_time=start_time,
|
|
||||||
end_time=end_time,
|
|
||||||
process_uuid=process_uuid,
|
|
||||||
channel_id=message.channel.id,
|
|
||||||
mute_role_id=honeypot_role.id
|
|
||||||
)
|
|
||||||
action_taken = "mute"
|
|
||||||
except discord.Forbidden:
|
|
||||||
logger.warning(f"Honeypot: no permission to mute {member.id} in guild {guild_id}")
|
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
await message.guild.ban(
|
await message.guild.ban(
|
||||||
@@ -2970,15 +2943,17 @@ async def on_message(message):
|
|||||||
action_taken = "ban"
|
action_taken = "ban"
|
||||||
except discord.Forbidden:
|
except discord.Forbidden:
|
||||||
logger.warning(f"Honeypot: no permission to ban {member.id} in guild {guild_id}")
|
logger.warning(f"Honeypot: no permission to ban {member.id} in guild {guild_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Honeypot ban failed for {member.id} in guild {guild_id}: {e}")
|
||||||
|
|
||||||
# Send log embed
|
|
||||||
hp_log_ch_id = guild_settings.get("honeypot_log_channel_id")
|
hp_log_ch_id = guild_settings.get("honeypot_log_channel_id")
|
||||||
if action_taken and hp_log_ch_id:
|
if action_taken and hp_log_ch_id:
|
||||||
try:
|
try:
|
||||||
log_ch = message.guild.get_channel(int(hp_log_ch_id))
|
log_ch = message.guild.get_channel(int(hp_log_ch_id))
|
||||||
if log_ch:
|
if log_ch:
|
||||||
color = 0x8b0000 if action_taken == "ban" else 0xff9500
|
color = 0x8B0000 if action_taken == "ban" else 0xFF9500
|
||||||
action_label = "🔨 Banned" if action_taken == "ban" else "🔇 Muted (1 year)"
|
action_label = "🔨 Banned" if action_taken == "ban" else "🔇 Muted (1 year)"
|
||||||
|
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
title="🍯 Honeypot triggered",
|
title="🍯 Honeypot triggered",
|
||||||
description=f"{member.mention} wrote in the honeypot channel.",
|
description=f"{member.mention} wrote in the honeypot channel.",
|
||||||
@@ -2988,25 +2963,31 @@ async def on_message(message):
|
|||||||
embed.add_field(name="Action", value=action_label, inline=True)
|
embed.add_field(name="Action", value=action_label, inline=True)
|
||||||
embed.add_field(name="User", value=f"{member} (`{member.id}`)", inline=True)
|
embed.add_field(name="User", value=f"{member} (`{member.id}`)", inline=True)
|
||||||
embed.add_field(name="Channel", value=f"<#{message.channel.id}>", inline=True)
|
embed.add_field(name="Channel", value=f"<#{message.channel.id}>", inline=True)
|
||||||
|
|
||||||
|
if action_taken == "mute":
|
||||||
|
embed.add_field(name="Mute Record ID", value=f"`{honeypot_mute_id}`", inline=True)
|
||||||
|
|
||||||
if preserve:
|
if preserve:
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name="Account age on server",
|
name="Account age on server",
|
||||||
value=f"{days_on_server if is_old_account else 'N/A'} days (Minimum: {acc_age_min}d)",
|
value=f"{days_on_server if is_old_account else 'N/A'} days (Minimum: {acc_age_min}d)",
|
||||||
inline=False
|
inline=False
|
||||||
)
|
)
|
||||||
|
|
||||||
if message.content:
|
if message.content:
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name="Message content",
|
name="Message content",
|
||||||
value=message.content[:500],
|
value=message.content[:500],
|
||||||
inline=False
|
inline=False
|
||||||
)
|
)
|
||||||
|
|
||||||
embed.set_thumbnail(url=member.display_avatar.url)
|
embed.set_thumbnail(url=member.display_avatar.url)
|
||||||
await log_ch.send(embed=embed)
|
await log_ch.send(embed=embed)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Honeypot: error sending log: {e}")
|
logger.error(f"Honeypot: error sending log: {e}")
|
||||||
|
|
||||||
return # Never process further for honeypot channel messages
|
return
|
||||||
# ── End honeypot check ──────────────────────────────────────────────────────
|
# ── End honeypot check ─────────────────────────────────────────────────────────
|
||||||
cooldown_key = (user_id, guild_id)
|
cooldown_key = (user_id, guild_id)
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
|
|
||||||
@@ -6491,120 +6472,95 @@ async def restorewarn(ctx, warning_id: int):
|
|||||||
if connection:
|
if connection:
|
||||||
close_database_connection(connection)
|
close_database_connection(connection)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@client.hybrid_command()
|
@client.hybrid_command()
|
||||||
async def mute(ctx, user: discord.User, duration: str, reason: str = "No reason provided", message_id: str = None, context_range: int = 3, silent: bool = False):
|
async def mute(
|
||||||
"""Mutes a user for a specified duration (Requires Permission Level 5 or higher)
|
ctx,
|
||||||
|
user: discord.User,
|
||||||
|
duration: str,
|
||||||
|
reason: str = "No reason provided",
|
||||||
|
message_id: str = None,
|
||||||
|
context_range: int = 3,
|
||||||
|
silent: bool = False
|
||||||
|
):
|
||||||
|
"""Mute a user for a specified duration."""
|
||||||
|
|
||||||
Usage:
|
is_slash_command = hasattr(ctx, "interaction") and ctx.interaction is not None
|
||||||
/mute @user 10m "Inappropriate behavior"
|
|
||||||
/mute @user 1h "Bad language" 1407754702564884622
|
|
||||||
/mute @user 1h "Bad language" 1407754702564884622 15
|
|
||||||
/mute @user 30m "Spamming" silent:True (silent mode - no public announcement)
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
- user: The user to mute
|
|
||||||
- duration: Duration (10m, 1h, 2d)
|
|
||||||
- reason: Reason for the mute (can include message ID and context range)
|
|
||||||
- message_id: Optional message ID to reference (required for context_range)
|
|
||||||
- context_range: Number of context messages to archive (only works with message_id)
|
|
||||||
- silent: If True, only send ephemeral response to mod (no public message)
|
|
||||||
|
|
||||||
Duration examples:
|
|
||||||
- 10m = 10 minutes
|
|
||||||
- 1h = 1 hour
|
|
||||||
- 2d = 2 days
|
|
||||||
|
|
||||||
You can also specify message ID and context range in the reason:
|
|
||||||
"Bad language 1407754702564884622 15" (15 messages before/after)
|
|
||||||
|
|
||||||
Note: context_range parameter only works when message_id is also provided!
|
|
||||||
"""
|
|
||||||
# Check if it's a slash command and defer if needed
|
|
||||||
is_slash_command = hasattr(ctx, 'interaction') and ctx.interaction
|
|
||||||
|
|
||||||
# For slash commands, always defer to ensure we have a response method
|
|
||||||
if is_slash_command:
|
if is_slash_command:
|
||||||
await ctx.defer(ephemeral=silent) # Defer as ephemeral if silent mode
|
await ctx.defer(ephemeral=silent)
|
||||||
|
|
||||||
# Helper function for sending responses
|
|
||||||
async def send_response(content=None, embed=None, ephemeral=False, file=None):
|
async def send_response(content=None, embed=None, ephemeral=False, file=None):
|
||||||
try:
|
try:
|
||||||
if is_slash_command:
|
if is_slash_command:
|
||||||
if hasattr(ctx, 'followup') and ctx.followup:
|
if hasattr(ctx, "followup") and ctx.followup:
|
||||||
await ctx.followup.send(content=content, embed=embed, ephemeral=ephemeral, file=file)
|
await ctx.followup.send(content=content, embed=embed, ephemeral=ephemeral, file=file)
|
||||||
elif hasattr(ctx, 'response') and not ctx.response.is_done():
|
elif hasattr(ctx, "interaction") and ctx.interaction:
|
||||||
await ctx.response.send_message(content=content, embed=embed, ephemeral=ephemeral, file=file)
|
await ctx.interaction.followup.send(content=content, embed=embed, ephemeral=ephemeral, file=file)
|
||||||
else:
|
else:
|
||||||
await ctx.send(content=content, embed=embed, file=file)
|
await ctx.send(content=content, embed=embed, file=file)
|
||||||
else:
|
else:
|
||||||
await ctx.send(content=content, embed=embed, file=file)
|
await ctx.send(content=content, embed=embed, file=file)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error sending response in mute command: {e}")
|
logger.error(f"Error sending mute response: {e}")
|
||||||
# Final fallback - try basic send
|
|
||||||
try:
|
try:
|
||||||
if embed:
|
if embed:
|
||||||
await ctx.send(embed=embed)
|
await ctx.send(embed=embed)
|
||||||
elif content:
|
elif content:
|
||||||
await ctx.send(content=content)
|
await ctx.send(content)
|
||||||
except Exception as fallback_error:
|
except Exception as fallback_error:
|
||||||
logger.error(f"Fallback send also failed: {fallback_error}")
|
logger.error(f"Fallback send failed: {fallback_error}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Parse message ID and context range from reason if they look valid
|
|
||||||
original_reason = reason
|
original_reason = reason
|
||||||
message_data = None
|
message_data = None
|
||||||
parsed_context_range = 3 # Default context range, only used if message_id is provided
|
parsed_context_range = 3
|
||||||
|
|
||||||
# Check if reason contains potential message ID and context range
|
|
||||||
reason_words = reason.split()
|
reason_words = reason.split()
|
||||||
if len(reason_words) >= 2:
|
if len(reason_words) >= 2:
|
||||||
# Check for pattern: "reason text 1234567890123456789 15"
|
potential_msg_id = reason_words[-2]
|
||||||
potential_msg_id = reason_words[-2] if len(reason_words) >= 2 else None
|
potential_context = reason_words[-1]
|
||||||
potential_context = reason_words[-1] if len(reason_words) >= 1 else None
|
|
||||||
|
|
||||||
# Check if last two elements are message ID and context range
|
if (
|
||||||
if (potential_msg_id and len(potential_msg_id) >= 17 and len(potential_msg_id) <= 20 and potential_msg_id.isdigit() and
|
potential_msg_id
|
||||||
potential_context and len(potential_context) <= 3 and potential_context.isdigit()):
|
and 17 <= len(potential_msg_id) <= 20
|
||||||
parsed_context_range = int(potential_context)
|
and potential_msg_id.isdigit()
|
||||||
message_id = potential_msg_id
|
and potential_context
|
||||||
reason = " ".join(reason_words[:-2]) # Remove both from reason
|
and len(potential_context) <= 3
|
||||||
elif len(reason_words) >= 1:
|
and potential_context.isdigit()
|
||||||
# Check if reason ends with a potential message ID only
|
):
|
||||||
potential_msg_id = reason_words[-1]
|
parsed_context_range = int(potential_context)
|
||||||
if len(potential_msg_id) >= 17 and len(potential_msg_id) <= 20 and potential_msg_id.isdigit():
|
|
||||||
message_id = potential_msg_id
|
message_id = potential_msg_id
|
||||||
reason = " ".join(reason_words[:-1]) # Remove message ID from reason
|
reason = " ".join(reason_words[:-2])
|
||||||
|
elif len(reason_words) >= 1:
|
||||||
|
potential_msg_id = reason_words[-1]
|
||||||
|
if 17 <= len(potential_msg_id) <= 20 and potential_msg_id.isdigit():
|
||||||
|
message_id = potential_msg_id
|
||||||
|
reason = " ".join(reason_words[:-1])
|
||||||
|
|
||||||
# Only use context_range parameter if message_id is also provided
|
|
||||||
if message_id and context_range != 3:
|
if message_id and context_range != 3:
|
||||||
# If message_id was provided and context_range was also set, use it
|
|
||||||
parsed_context_range = context_range
|
parsed_context_range = context_range
|
||||||
elif not message_id:
|
elif not message_id:
|
||||||
# If no message_id was found, reset context_range to default
|
|
||||||
parsed_context_range = 3
|
parsed_context_range = 3
|
||||||
|
|
||||||
# Validate and limit context range
|
|
||||||
if parsed_context_range < 1:
|
if parsed_context_range < 1:
|
||||||
parsed_context_range = 1
|
parsed_context_range = 1
|
||||||
elif parsed_context_range > 25:
|
elif parsed_context_range > 25:
|
||||||
parsed_context_range = 25
|
parsed_context_range = 25
|
||||||
|
|
||||||
# Try to get message data if message ID was provided
|
|
||||||
if message_id:
|
if message_id:
|
||||||
try:
|
try:
|
||||||
message_id_int = int(message_id)
|
message_id_int = int(message_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await send_response(content=f"❌ Invalid message ID: {message_id}")
|
await send_response(content=f"❌ Invalid message ID: {message_id}", ephemeral=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Try to get message data from current channel first
|
|
||||||
message_data = await get_message_data(ctx.channel, message_id_int, context_range=parsed_context_range)
|
message_data = await get_message_data(ctx.channel, message_id_int, context_range=parsed_context_range)
|
||||||
|
|
||||||
# If not found in current channel, try other channels
|
|
||||||
if message_data is None:
|
if message_data is None:
|
||||||
# Limit search to avoid spam - only check first 10 channels plus current channel
|
|
||||||
channels_to_check = [ctx.channel] + [ch for ch in ctx.guild.text_channels[:10] if ch.id != ctx.channel.id]
|
channels_to_check = [ctx.channel] + [ch for ch in ctx.guild.text_channels[:10] if ch.id != ctx.channel.id]
|
||||||
for channel in channels_to_check[1:]: # Skip current channel, already checked
|
for channel in channels_to_check[1:]:
|
||||||
try:
|
try:
|
||||||
message_data = await get_message_data(channel, message_id_int, context_range=parsed_context_range)
|
message_data = await get_message_data(channel, message_id_int, context_range=parsed_context_range)
|
||||||
if message_data is not None:
|
if message_data is not None:
|
||||||
@@ -6612,10 +6568,8 @@ async def mute(ctx, user: discord.User, duration: str, reason: str = "No reason
|
|||||||
except discord.Forbidden:
|
except discord.Forbidden:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Load moderator data
|
|
||||||
mod_data = await load_user_data(ctx.author.id, ctx.guild.id)
|
mod_data = await load_user_data(ctx.author.id, ctx.guild.id)
|
||||||
|
|
||||||
# Check moderation rights
|
|
||||||
if not check_moderation_permission(mod_data["permission"]):
|
if not check_moderation_permission(mod_data["permission"]):
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
title="❌ Insufficient Permissions",
|
title="❌ Insufficient Permissions",
|
||||||
@@ -6625,7 +6579,6 @@ async def mute(ctx, user: discord.User, duration: str, reason: str = "No reason
|
|||||||
await send_response(embed=embed, ephemeral=True)
|
await send_response(embed=embed, ephemeral=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Cannot mute yourself
|
|
||||||
if user.id == ctx.author.id:
|
if user.id == ctx.author.id:
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
title="❌ Invalid Action",
|
title="❌ Invalid Action",
|
||||||
@@ -6635,9 +6588,8 @@ async def mute(ctx, user: discord.User, duration: str, reason: str = "No reason
|
|||||||
await send_response(embed=embed, ephemeral=True)
|
await send_response(embed=embed, ephemeral=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Parse duration
|
time_units = {"m": 60, "h": 3600, "d": 86400}
|
||||||
time_units = {'m': 60, 'h': 3600, 'd': 86400}
|
if not duration or duration[-1] not in time_units or not duration[:-1].isdigit():
|
||||||
if not duration or not duration[-1] in time_units or not duration[:-1].isdigit():
|
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
title="❌ Invalid Duration",
|
title="❌ Invalid Duration",
|
||||||
description="Invalid time format. Use: 10m, 1h, 2d",
|
description="Invalid time format. Use: 10m, 1h, 2d",
|
||||||
@@ -6647,9 +6599,7 @@ async def mute(ctx, user: discord.User, duration: str, reason: str = "No reason
|
|||||||
return
|
return
|
||||||
|
|
||||||
duration_seconds = int(duration[:-1]) * time_units[duration[-1]]
|
duration_seconds = int(duration[:-1]) * time_units[duration[-1]]
|
||||||
end_time = datetime.now() + timedelta(seconds=duration_seconds)
|
|
||||||
|
|
||||||
# Get member object
|
|
||||||
member = ctx.guild.get_member(user.id)
|
member = ctx.guild.get_member(user.id)
|
||||||
if not member:
|
if not member:
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
@@ -6660,73 +6610,27 @@ async def mute(ctx, user: discord.User, duration: str, reason: str = "No reason
|
|||||||
await send_response(embed=embed, ephemeral=True)
|
await send_response(embed=embed, ephemeral=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Load guild settings
|
result = await apply_mute_action(
|
||||||
guild_settings = get_guild_settings(ctx.guild.id)
|
guild=ctx.guild,
|
||||||
|
member=member,
|
||||||
# Save current roles
|
moderator=ctx.author,
|
||||||
await save_user_roles(user.id, ctx.guild.id, member.roles)
|
duration_seconds=duration_seconds,
|
||||||
|
duration_label=duration,
|
||||||
# Remove all roles except @everyone
|
|
||||||
roles_to_remove = [role for role in member.roles if not role.is_default()]
|
|
||||||
if roles_to_remove:
|
|
||||||
await member.remove_roles(*roles_to_remove, reason=f"Muted by {ctx.author}")
|
|
||||||
|
|
||||||
# Get or create mute role
|
|
||||||
mute_role = await get_or_create_mute_role(ctx.guild, guild_settings)
|
|
||||||
if not mute_role:
|
|
||||||
embed = discord.Embed(
|
|
||||||
title="❌ Mute Role Error",
|
|
||||||
description="Could not find or create mute role. Check server settings.",
|
|
||||||
color=0xff0000
|
|
||||||
)
|
|
||||||
await send_response(embed=embed, ephemeral=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Add mute role
|
|
||||||
await member.add_roles(mute_role, reason=f"Muted by {ctx.author} for {duration}")
|
|
||||||
|
|
||||||
# Update user data
|
|
||||||
user_data = await load_user_data(user.id, ctx.guild.id)
|
|
||||||
user_data["mutes"] += 1
|
|
||||||
update_user_data(user.id, ctx.guild.id, "mutes", user_data["mutes"])
|
|
||||||
|
|
||||||
# Create active process for auto-unmute
|
|
||||||
process_data = {
|
|
||||||
"user_id": user.id,
|
|
||||||
"guild_id": ctx.guild.id,
|
|
||||||
"channel_id": ctx.channel.id,
|
|
||||||
"reason": reason,
|
|
||||||
"moderator_id": ctx.author.id,
|
|
||||||
"mute_role_id": mute_role.id
|
|
||||||
}
|
|
||||||
|
|
||||||
process_uuid = create_active_process(
|
|
||||||
process_type="mute",
|
|
||||||
guild_id=ctx.guild.id,
|
|
||||||
channel_id=ctx.channel.id,
|
|
||||||
user_id=user.id,
|
|
||||||
target_id=user.id,
|
|
||||||
end_time=end_time,
|
|
||||||
data=process_data
|
|
||||||
)
|
|
||||||
|
|
||||||
# Save detailed mute record to database
|
|
||||||
mute_id = await save_mute_to_database(
|
|
||||||
user_id=user.id,
|
|
||||||
guild_id=ctx.guild.id,
|
|
||||||
moderator_id=ctx.author.id,
|
|
||||||
reason=reason,
|
reason=reason,
|
||||||
duration=duration,
|
source_channel=ctx.channel,
|
||||||
start_time=datetime.now(),
|
|
||||||
end_time=end_time,
|
|
||||||
process_uuid=process_uuid,
|
|
||||||
channel_id=ctx.channel.id,
|
|
||||||
mute_role_id=mute_role.id,
|
|
||||||
message_data=message_data,
|
message_data=message_data,
|
||||||
message_id=int(message_id) if message_id else None
|
message_id=int(message_id) if message_id else None,
|
||||||
|
remove_existing_roles=True,
|
||||||
|
save_removed_roles=True,
|
||||||
|
increment_mute_count=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create embed
|
end_time = result["end_time"]
|
||||||
|
mute_id = result["mute_id"]
|
||||||
|
process_uuid = result["process_uuid"]
|
||||||
|
mute_role = result["mute_role"]
|
||||||
|
user_data = result["user_data"]
|
||||||
|
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
title="🔇 User Muted",
|
title="🔇 User Muted",
|
||||||
description=f"{user.mention} has been muted.",
|
description=f"{user.mention} has been muted.",
|
||||||
@@ -6735,62 +6639,54 @@ async def mute(ctx, user: discord.User, duration: str, reason: str = "No reason
|
|||||||
)
|
)
|
||||||
embed.add_field(name="⏱️ Duration", value=duration, inline=True)
|
embed.add_field(name="⏱️ Duration", value=duration, inline=True)
|
||||||
embed.add_field(name="⏰ Ends At", value=f"<t:{int(end_time.timestamp())}:F>", inline=True)
|
embed.add_field(name="⏰ Ends At", value=f"<t:{int(end_time.timestamp())}:F>", inline=True)
|
||||||
embed.add_field(name="📝 Reason", value=reason, inline=False)
|
embed.add_field(name="📝 Reason", value=reason or "No reason provided", inline=False)
|
||||||
embed.add_field(name="👮 Moderator", value=ctx.author.mention, inline=True)
|
embed.add_field(name="👮 Moderator", value=ctx.author.mention, inline=True)
|
||||||
embed.add_field(name="🔇 Mute Count", value=f"{user_data['mutes']}", inline=True)
|
embed.add_field(name="🔇 Mute Count", value=str(user_data["mutes"]), inline=True)
|
||||||
|
|
||||||
# Add message information if available
|
|
||||||
if message_data:
|
if message_data:
|
||||||
# Handle new context message format
|
|
||||||
if isinstance(message_data, dict) and "main_message" in message_data:
|
if isinstance(message_data, dict) and "main_message" in message_data:
|
||||||
main_msg = message_data.get("main_message")
|
main_msg = message_data.get("main_message")
|
||||||
context_msgs = message_data.get("context_messages", [])
|
|
||||||
|
|
||||||
if main_msg:
|
if main_msg:
|
||||||
message_info = f"**Message ID:** `{main_msg['id']}`\n"
|
message_info = f"**Message ID:** `{main_msg['id']}`\n"
|
||||||
message_info += f"**Channel:** <#{main_msg['channel_id']}>\n"
|
message_info += f"**Channel:** <#{main_msg['channel_id']}>\n"
|
||||||
message_info += f"**Author:** {main_msg['author_name']}\n"
|
message_info += f"**Author:** {main_msg['author_name']}\n"
|
||||||
|
if main_msg.get("content"):
|
||||||
if main_msg.get('content'):
|
content_preview = main_msg["content"][:200] + "..." if len(main_msg["content"]) > 200 else main_msg["content"]
|
||||||
content_preview = main_msg['content'][:200] + "..." if len(main_msg['content']) > 200 else main_msg['content']
|
|
||||||
message_info += f"**Content:** {content_preview}"
|
message_info += f"**Content:** {content_preview}"
|
||||||
|
|
||||||
embed.add_field(name="📄 Referenced Message", value=message_info, inline=False)
|
embed.add_field(name="📄 Referenced Message", value=message_info, inline=False)
|
||||||
|
|
||||||
# Process attachments for archival if any
|
if main_msg.get("attachments"):
|
||||||
if main_msg.get('attachments'):
|
|
||||||
try:
|
try:
|
||||||
attachments_data = json.loads(main_msg['attachments'])
|
attachments_data = json.loads(main_msg["attachments"])
|
||||||
if attachments_data:
|
if attachments_data:
|
||||||
attachment_info = ""
|
attachment_info = ""
|
||||||
for i, att in enumerate(attachments_data[:3]): # Show first 3 attachments
|
for i, att in enumerate(attachments_data[:3]):
|
||||||
attachment_info += f"• {att.get('filename', 'Unknown file')}\n"
|
attachment_info += f"• {att.get('filename', 'Unknown file')}\n"
|
||||||
if len(attachments_data) > 3:
|
if len(attachments_data) > 3:
|
||||||
attachment_info += f"• +{len(attachments_data) - 3} more attachments"
|
attachment_info += f"• +{len(attachments_data) - 3} more attachments"
|
||||||
embed.add_field(name="📎 Archived Attachments", value=attachment_info, inline=False)
|
embed.add_field(name="📎 Archived Attachments", value=attachment_info, inline=False)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
# Handle old format for backward compatibility
|
|
||||||
message_info = f"**Message ID:** `{message_data.get('id', 'Unknown')}`\n"
|
message_info = f"**Message ID:** `{message_data.get('id', 'Unknown')}`\n"
|
||||||
message_info += f"**Channel:** <#{message_data.get('channel_id', 'Unknown')}>\n"
|
message_info += f"**Channel:** <#{message_data.get('channel_id', 'Unknown')}>\n"
|
||||||
message_info += f"**Author:** <@{message_data.get('author_id', 'Unknown')}>\n"
|
message_info += f"**Author:** <@{message_data.get('author_id', 'Unknown')}>\n"
|
||||||
if message_data.get('content'):
|
if message_data.get("content"):
|
||||||
content_preview = message_data['content'][:200] + "..." if len(message_data['content']) > 200 else message_data['content']
|
content_preview = message_data["content"][:200] + "..." if len(message_data["content"]) > 200 else message_data["content"]
|
||||||
message_info += f"**Content:** {content_preview}"
|
message_info += f"**Content:** {content_preview}"
|
||||||
embed.add_field(name="📄 Referenced Message", value=message_info, inline=False)
|
embed.add_field(name="📄 Referenced Message", value=message_info, inline=False)
|
||||||
elif message_id:
|
elif message_id:
|
||||||
embed.add_field(name="📄 Referenced Message", value=f"Message ID: `{message_id}` (Message not found or inaccessible)", inline=False)
|
embed.add_field(
|
||||||
|
name="📄 Referenced Message",
|
||||||
|
value=f"Message ID: `{message_id}` (Message not found or inaccessible)",
|
||||||
|
inline=False
|
||||||
|
)
|
||||||
|
|
||||||
# Add Mute Record ID field
|
|
||||||
embed.add_field(name="🆔 Mute Record ID", value=f"`{mute_id}`", inline=True)
|
embed.add_field(name="🆔 Mute Record ID", value=f"`{mute_id}`", inline=True)
|
||||||
|
|
||||||
embed.set_footer(text=f"User ID: {user.id} | Process ID: {str(process_uuid)[:8]} | Use /viewmute {mute_id} for details")
|
embed.set_footer(text=f"User ID: {user.id} | Process ID: {str(process_uuid)[:8]} | Use /viewmute {mute_id} for details")
|
||||||
embed.set_thumbnail(url=user.display_avatar.url)
|
embed.set_thumbnail(url=user.display_avatar.url)
|
||||||
|
|
||||||
# Send response based on silent mode
|
|
||||||
if silent:
|
if silent:
|
||||||
# Silent mode: ephemeral response to moderator only
|
|
||||||
silent_embed = discord.Embed(
|
silent_embed = discord.Embed(
|
||||||
title="🔇 Silent Mute Applied",
|
title="🔇 Silent Mute Applied",
|
||||||
description=f"{user.mention} has been muted silently.",
|
description=f"{user.mention} has been muted silently.",
|
||||||
@@ -6799,80 +6695,44 @@ async def mute(ctx, user: discord.User, duration: str, reason: str = "No reason
|
|||||||
)
|
)
|
||||||
silent_embed.add_field(name="⏱️ Duration", value=duration, inline=True)
|
silent_embed.add_field(name="⏱️ Duration", value=duration, inline=True)
|
||||||
silent_embed.add_field(name="⏰ Ends At", value=f"<t:{int(end_time.timestamp())}:F>", inline=True)
|
silent_embed.add_field(name="⏰ Ends At", value=f"<t:{int(end_time.timestamp())}:F>", inline=True)
|
||||||
silent_embed.add_field(name="📝 Reason", value=reason, inline=False)
|
silent_embed.add_field(name="📝 Reason", value=reason or "No reason provided", inline=False)
|
||||||
silent_embed.add_field(name="🔇 Mute Count", value=f"{user_data['mutes']}", inline=True)
|
silent_embed.add_field(name="🔇 Mute Count", value=str(user_data["mutes"]), inline=True)
|
||||||
silent_embed.add_field(name="🆔 Mute Record ID", value=f"`{mute_id}`", inline=True)
|
silent_embed.add_field(name="🆔 Mute Record ID", value=f"`{mute_id}`", inline=True)
|
||||||
|
silent_embed.add_field(
|
||||||
silent_embed.add_field(name="🔔 Actions Taken",
|
name="🔔 Actions Taken",
|
||||||
value="• User muted with timeout role\n• User received DM notification\n• Mod log entry created\n• No public announcement",
|
value="• User muted\n• User received DM notification if possible\n• Mod log entry created\n• No public announcement",
|
||||||
inline=False)
|
inline=False
|
||||||
|
)
|
||||||
silent_embed.set_footer(text=f"Silent Mode • User ID: {user.id} | Use /viewmute {mute_id} for details")
|
silent_embed.set_footer(text=f"Silent Mode • User ID: {user.id} | Use /viewmute {mute_id} for details")
|
||||||
silent_embed.set_thumbnail(url=user.display_avatar.url)
|
silent_embed.set_thumbnail(url=user.display_avatar.url)
|
||||||
|
|
||||||
# Send ephemeral response - use followup since we deferred
|
|
||||||
try:
|
try:
|
||||||
if is_slash_command:
|
if is_slash_command:
|
||||||
# Since we deferred with ephemeral=silent, use followup
|
if hasattr(ctx, "followup") and ctx.followup is not None:
|
||||||
# Make sure followup is available and properly initialized
|
|
||||||
if hasattr(ctx, 'followup') and ctx.followup is not None:
|
|
||||||
await ctx.followup.send(embed=silent_embed, ephemeral=True)
|
await ctx.followup.send(embed=silent_embed, ephemeral=True)
|
||||||
logger.info(f"Silent mute sent via ctx.followup.send (ephemeral)")
|
elif hasattr(ctx, "interaction") and ctx.interaction:
|
||||||
elif hasattr(ctx, 'interaction') and ctx.interaction:
|
|
||||||
# Direct interaction followup as fallback
|
|
||||||
await ctx.interaction.followup.send(embed=silent_embed, ephemeral=True)
|
await ctx.interaction.followup.send(embed=silent_embed, ephemeral=True)
|
||||||
logger.info(f"Silent mute sent via ctx.interaction.followup.send (ephemeral)")
|
|
||||||
else:
|
else:
|
||||||
logger.error(f"Silent mute failed: No followup available - ctx.followup: {getattr(ctx, 'followup', None)}")
|
raise RuntimeError("No followup available after defer")
|
||||||
raise Exception("No followup available after defer")
|
|
||||||
else:
|
else:
|
||||||
# For prefix commands, we can't do true ephemeral, so log error instead
|
await ctx.send(embed=silent_embed)
|
||||||
logger.error(f"Silent mute attempted with prefix command - not supported")
|
|
||||||
raise Exception("Silent mode only works with slash commands")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error sending silent mute response: {e}")
|
logger.error(f"Error sending silent mute response: {e}")
|
||||||
# Send error to mod log instead of fallback message
|
|
||||||
try:
|
|
||||||
await log_moderation_action(
|
|
||||||
guild=ctx.guild,
|
|
||||||
action_type="mute_error",
|
|
||||||
moderator=ctx.author,
|
|
||||||
target_user=user,
|
|
||||||
reason=f"Silent mute issued but ephemeral response failed: {str(e)}",
|
|
||||||
duration=duration,
|
|
||||||
additional_info={
|
|
||||||
"Original Reason": reason,
|
|
||||||
"Mute ID": str(mute_id) if mute_id else "N/A",
|
|
||||||
"Duration": duration,
|
|
||||||
"Error Details": str(e),
|
|
||||||
"Command Type": "Slash" if is_slash_command else "Prefix",
|
|
||||||
"Fallback": "User received DM notification normally"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception as log_error:
|
|
||||||
logger.error(f"Failed to log silent mute error: {log_error}")
|
|
||||||
|
|
||||||
# Silent mode is complete - exit here to prevent normal logging/responses
|
|
||||||
return
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Normal mode: public response
|
|
||||||
await send_response(embed=embed)
|
await send_response(embed=embed)
|
||||||
|
|
||||||
# Log the mute action
|
|
||||||
log_additional_info = {
|
log_additional_info = {
|
||||||
"Mute Count": str(user_data['mutes']),
|
"Mute Count": str(user_data["mutes"]),
|
||||||
"Process ID": str(process_uuid)[:8],
|
"Process ID": str(process_uuid)[:8],
|
||||||
"Mute Record ID": str(mute_id)
|
"Mute Record ID": str(mute_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if message_data:
|
if message_data:
|
||||||
# Handle new context message format
|
|
||||||
if isinstance(message_data, dict) and "main_message" in message_data:
|
if isinstance(message_data, dict) and "main_message" in message_data:
|
||||||
main_msg = message_data.get("main_message")
|
main_msg = message_data.get("main_message")
|
||||||
if main_msg:
|
if main_msg:
|
||||||
log_additional_info["Referenced Message"] = f"ID: {main_msg['id']} in <#{main_msg['channel_id']}>"
|
log_additional_info["Referenced Message"] = f"ID: {main_msg['id']} in <#{main_msg['channel_id']}>"
|
||||||
else:
|
else:
|
||||||
# Handle old format
|
|
||||||
log_additional_info["Referenced Message"] = f"ID: {message_data['id']} in <#{message_data['channel_id']}>"
|
log_additional_info["Referenced Message"] = f"ID: {message_data['id']} in <#{message_data['channel_id']}>"
|
||||||
|
|
||||||
await log_moderation_action(
|
await log_moderation_action(
|
||||||
@@ -6885,7 +6745,6 @@ async def mute(ctx, user: discord.User, duration: str, reason: str = "No reason
|
|||||||
additional_info=log_additional_info
|
additional_info=log_additional_info
|
||||||
)
|
)
|
||||||
|
|
||||||
# Try to DM the user
|
|
||||||
try:
|
try:
|
||||||
dm_embed = discord.Embed(
|
dm_embed = discord.Embed(
|
||||||
title="🔇 You have been muted",
|
title="🔇 You have been muted",
|
||||||
@@ -6895,19 +6754,27 @@ async def mute(ctx, user: discord.User, duration: str, reason: str = "No reason
|
|||||||
)
|
)
|
||||||
dm_embed.add_field(name="⏱️ Duration", value=duration, inline=True)
|
dm_embed.add_field(name="⏱️ Duration", value=duration, inline=True)
|
||||||
dm_embed.add_field(name="⏰ Ends At", value=f"<t:{int(end_time.timestamp())}:F>", inline=True)
|
dm_embed.add_field(name="⏰ Ends At", value=f"<t:{int(end_time.timestamp())}:F>", inline=True)
|
||||||
dm_embed.add_field(name="📝 Reason", value=reason, inline=False)
|
dm_embed.add_field(name="📝 Reason", value=reason or "No reason provided", inline=False)
|
||||||
dm_embed.add_field(name="👮 Moderator", value=ctx.author.display_name, inline=True)
|
dm_embed.add_field(name="👮 Moderator", value=ctx.author.display_name, inline=True)
|
||||||
|
|
||||||
if message_data and message_data['content']:
|
if message_data:
|
||||||
content_preview = message_data['content'][:200] + "..." if len(message_data['content']) > 200 else message_data['content']
|
preview_content = None
|
||||||
dm_embed.add_field(name="📄 Referenced Message", value=f"```{content_preview}```", inline=False)
|
if isinstance(message_data, dict) and "main_message" in message_data:
|
||||||
|
main_msg = message_data.get("main_message")
|
||||||
|
if main_msg:
|
||||||
|
preview_content = main_msg.get("content")
|
||||||
|
else:
|
||||||
|
preview_content = message_data.get("content")
|
||||||
|
|
||||||
|
if preview_content:
|
||||||
|
content_preview = preview_content[:200] + "..." if len(preview_content) > 200 else preview_content
|
||||||
|
dm_embed.add_field(name="📄 Referenced Message", value=f"```{content_preview}```", inline=False)
|
||||||
|
|
||||||
dm_embed.set_footer(text=f"Server: {ctx.guild.name}")
|
dm_embed.set_footer(text=f"Server: {ctx.guild.name}")
|
||||||
await user.send(embed=dm_embed)
|
await user.send(embed=dm_embed)
|
||||||
except discord.Forbidden:
|
except discord.Forbidden:
|
||||||
pass # User has DMs disabled
|
pass
|
||||||
|
|
||||||
# Log the action
|
|
||||||
logger.info(f"User {user.id} muted by {ctx.author.id} in guild {ctx.guild.id} for {duration}. Reason: {reason}")
|
logger.info(f"User {user.id} muted by {ctx.author.id} in guild {ctx.guild.id} for {duration}. Reason: {reason}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -6917,7 +6784,88 @@ async def mute(ctx, user: discord.User, duration: str, reason: str = "No reason
|
|||||||
description="An error occurred while processing the mute. Please try again.",
|
description="An error occurred while processing the mute. Please try again.",
|
||||||
color=0xff0000
|
color=0xff0000
|
||||||
)
|
)
|
||||||
await send_response(embed=embed)
|
await send_response(embed=embed, ephemeral=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_mute_action(
|
||||||
|
*,
|
||||||
|
guild: discord.Guild,
|
||||||
|
member: discord.Member,
|
||||||
|
moderator,
|
||||||
|
duration_seconds: int,
|
||||||
|
duration_label: str,
|
||||||
|
reason: str,
|
||||||
|
source_channel=None,
|
||||||
|
message_data: dict | None = None,
|
||||||
|
message_id: int | None = None,
|
||||||
|
remove_existing_roles: bool = True,
|
||||||
|
save_removed_roles: bool = True,
|
||||||
|
increment_mute_count: bool = True
|
||||||
|
):
|
||||||
|
guild_settings = get_guild_settings(guild.id)
|
||||||
|
|
||||||
|
if save_removed_roles:
|
||||||
|
await save_user_roles(member.id, guild.id, member.roles)
|
||||||
|
|
||||||
|
if remove_existing_roles:
|
||||||
|
roles_to_remove = [role for role in member.roles if not role.is_default()]
|
||||||
|
if roles_to_remove:
|
||||||
|
await member.remove_roles(*roles_to_remove, reason=f"Muted by {moderator}")
|
||||||
|
|
||||||
|
mute_role = await get_or_create_mute_role(guild, guild_settings)
|
||||||
|
if not mute_role:
|
||||||
|
raise RuntimeError("Could not find or create mute role")
|
||||||
|
|
||||||
|
await member.add_roles(mute_role, reason=f"Muted by {moderator} for {duration_label}")
|
||||||
|
|
||||||
|
user_data = await load_user_data(member.id, guild.id)
|
||||||
|
if increment_mute_count:
|
||||||
|
user_data["mutes"] += 1
|
||||||
|
update_user_data(member.id, guild.id, "mutes", user_data["mutes"])
|
||||||
|
|
||||||
|
end_time = datetime.now() + timedelta(seconds=duration_seconds)
|
||||||
|
|
||||||
|
process_data = {
|
||||||
|
"user_id": member.id,
|
||||||
|
"guild_id": guild.id,
|
||||||
|
"channel_id": source_channel.id if source_channel else None,
|
||||||
|
"reason": reason,
|
||||||
|
"moderator_id": moderator.id,
|
||||||
|
"mute_role_id": mute_role.id
|
||||||
|
}
|
||||||
|
|
||||||
|
process_uuid = create_active_process(
|
||||||
|
process_type="mute",
|
||||||
|
guild_id=guild.id,
|
||||||
|
channel_id=source_channel.id if source_channel else 0,
|
||||||
|
user_id=member.id,
|
||||||
|
target_id=member.id,
|
||||||
|
end_time=end_time,
|
||||||
|
data=process_data
|
||||||
|
)
|
||||||
|
|
||||||
|
mute_id = await save_mute_to_database(
|
||||||
|
user_id=member.id,
|
||||||
|
guild_id=guild.id,
|
||||||
|
moderator_id=moderator.id,
|
||||||
|
reason=reason,
|
||||||
|
duration=duration_label,
|
||||||
|
start_time=datetime.now(),
|
||||||
|
end_time=end_time,
|
||||||
|
process_uuid=process_uuid,
|
||||||
|
channel_id=source_channel.id if source_channel else None,
|
||||||
|
mute_role_id=mute_role.id,
|
||||||
|
message_data=message_data,
|
||||||
|
message_id=message_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mute_role": mute_role,
|
||||||
|
"user_data": user_data,
|
||||||
|
"end_time": end_time,
|
||||||
|
"process_uuid": process_uuid,
|
||||||
|
"mute_id": mute_id
|
||||||
|
}
|
||||||
|
|
||||||
@client.hybrid_command()
|
@client.hybrid_command()
|
||||||
async def unmute(ctx, user: discord.User):
|
async def unmute(ctx, user: discord.User):
|
||||||
|
|||||||
Reference in New Issue
Block a user