discordbot/cogs/welcome_cog.py
Slipstreamm a2eb6f33f1 refactor(welcome): Consolidate info displays in welcome/goodbye messages
Group related member information and server statistics into single
TextDisplay components. This streamlines the UI layout, reduces the
number of elements, and potentially improves rendering efficiency.
2025-06-14 14:14:19 -06:00

655 lines
26 KiB
Python

import discord
from discord.ext import commands
from discord import ui
import logging
import sys
import os
from datetime import datetime, timedelta, timezone
from typing import Literal, Optional
# Add the parent directory to sys.path to ensure settings_manager is accessible
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import settings_manager
log = logging.getLogger(__name__)
# --- Message Component Views ---
class WelcomeMessageView(ui.LayoutView):
"""An enhanced view for welcome messages with member count and improved styling."""
def __init__(self, member: discord.Member, message: str, member_count: int):
super().__init__(timeout=None)
# Use member's color or a nice default
accent_color = member.color if member.color != discord.Color.default() else discord.Color.green()
container = ui.Container(accent_colour=accent_color)
# Add a welcome banner/header with avatar
header_section = ui.Section(
accessory=ui.Thumbnail(
media=member.display_avatar.url,
description="New Member Avatar",
)
)
# Welcome title with emoji
header_section.add_item(ui.TextDisplay("🎉 **Welcome to the Server!** 🎉"))
header_section.add_item(ui.TextDisplay(message))
# Add member info to the header section
# Calculate account age
account_age = datetime.now(timezone.utc) - member.created_at
if account_age.days >= 365:
years = account_age.days // 365
months = (account_age.days % 365) // 30
age_str = f"{years} year{'s' if years != 1 else ''}, {months} month{'s' if months != 1 else ''} ago"
elif account_age.days >= 30:
months = account_age.days // 30
age_str = f"{months} month{'s' if months != 1 else ''} ago"
else:
age_str = f"{account_age.days} day{'s' if account_age.days != 1 else ''} ago"
member_info = (
f"**Member:** {member.display_name}\n"
f"**Account Created:** {member.created_at.strftime('%B %d, %Y')}\n"
f"**Account Age:** {age_str}"
)
header_section.add_item(ui.TextDisplay(member_info))
# Add server stats to the header section
server_stats = (
f"📊 **Server Statistics**\n"
f"**Total Members:** {member_count:,}\n"
f"**You are member #{member_count:,}**"
)
header_section.add_item(ui.TextDisplay(server_stats))
# Add welcome footer
header_section.add_item(ui.TextDisplay("💬 Feel free to introduce yourself and have fun!"))
container.add_item(header_section)
self.add_item(container)
class GoodbyeMessageView(ui.LayoutView):
"""An enhanced view for goodbye messages with member count and improved styling."""
def __init__(self, member: discord.Member, message: str, member_count: int, reason: str = "left"):
super().__init__(timeout=None)
# Use darker colors for goodbye messages
if reason == "banned":
accent_color = discord.Color.red()
emoji = "🔨"
title = "Member Banned"
elif reason == "kicked":
accent_color = discord.Color.orange()
emoji = "👢"
title = "Member Kicked"
else:
accent_color = discord.Color.dark_grey()
emoji = "👋"
title = "Member Left"
container = ui.Container(accent_colour=accent_color)
# Header section with avatar
header_section = ui.Section(
accessory=ui.Thumbnail(
media=member.display_avatar.url,
description="Former Member Avatar",
)
)
header_section.add_item(ui.TextDisplay(f"{emoji} **{title}** {emoji}"))
header_section.add_item(ui.TextDisplay(message))
# Add member info to the header section
member_info_parts = [
f"**Member:** {member.display_name}",
f"**Username:** {member.name}"
]
# Show join date if available
if hasattr(member, 'joined_at') and member.joined_at:
join_date = member.joined_at.strftime('%B %d, %Y')
time_in_server = datetime.now(timezone.utc) - member.joined_at
if time_in_server.days >= 365:
years = time_in_server.days // 365
months = (time_in_server.days % 365) // 30
duration_str = f"{years} year{'s' if years != 1 else ''}, {months} month{'s' if months != 1 else ''}"
elif time_in_server.days >= 30:
months = time_in_server.days // 30
duration_str = f"{months} month{'s' if months != 1 else ''}"
else:
duration_str = f"{time_in_server.days} day{'s' if time_in_server.days != 1 else ''}"
member_info_parts.append(f"**Joined:** {join_date}")
member_info_parts.append(f"**Time in Server:** {duration_str}")
header_section.add_item(ui.TextDisplay("\n".join(member_info_parts)))
# Add server stats to the header section
server_stats = (
f"📊 **Server Statistics**\n"
f"**Current Members:** {member_count:,}"
)
header_section.add_item(ui.TextDisplay(server_stats))
container.add_item(header_section)
self.add_item(container)
class WelcomeCog(commands.Cog):
"""Handles welcome and goodbye messages for guilds."""
def __init__(self, bot: commands.Bot):
self.bot = bot
self.bot.add_listener(self.on_member_join, "on_member_join")
self.bot.add_listener(self.on_member_remove, "on_member_remove")
async def on_member_join(self, member: discord.Member):
"""Sends a welcome message when a new member joins."""
guild = member.guild
if not guild:
return
log.debug(f"Member {member.name} joined guild {guild.name} ({guild.id})")
# --- Fetch settings ---
welcome_channel_id_str = await settings_manager.get_setting(
guild.id, "welcome_channel_id"
)
welcome_message_template = await settings_manager.get_setting(
guild.id, "welcome_message", default="Welcome {user} to {server}!"
)
if not welcome_channel_id_str or welcome_channel_id_str == "__NONE__":
log.debug(f"Welcome channel not configured for guild {guild.id}")
return
try:
welcome_channel_id = int(welcome_channel_id_str)
channel = guild.get_channel(welcome_channel_id)
if not channel or not isinstance(channel, discord.TextChannel):
log.warning(
f"Welcome channel ID {welcome_channel_id} not found or not text channel in guild {guild.id}"
)
return
# --- Format and send message ---
formatted_message = welcome_message_template.format(
user=member.mention, username=member.name, server=guild.name
)
# Get current member count
member_count = guild.member_count or len(guild.members)
view = WelcomeMessageView(member, formatted_message, member_count)
await channel.send(view=view)
log.info(f"Sent welcome message for {member.name} in guild {guild.id}")
except ValueError as e:
log.error(
f"ValueError in WelcomeCog for guild {guild.id}: {e}"
)
except discord.Forbidden:
log.error(
f"Missing permissions to send welcome message in channel {welcome_channel_id} for guild {guild.id}"
)
except Exception as e:
log.exception(f"Error sending welcome message for guild {guild.id}: {e}")
async def on_member_remove(self, member: discord.Member):
"""Sends a goodbye message when a member leaves, is kicked, or is banned."""
guild = member.guild
if not guild:
return
log.debug(f"Member {member.name} left guild {guild.name} ({guild.id})")
# --- Fetch settings ---
goodbye_channel_id_str = await settings_manager.get_setting(
guild.id, "goodbye_channel_id"
)
goodbye_message_template = await settings_manager.get_setting(
guild.id, "goodbye_message", default="{username} has left the server."
)
if not goodbye_channel_id_str or goodbye_channel_id_str == "__NONE__":
log.debug(f"Goodbye channel not configured for guild {guild.id}")
return
try:
goodbye_channel_id = int(goodbye_channel_id_str)
channel = guild.get_channel(goodbye_channel_id)
if not channel or not isinstance(channel, discord.TextChannel):
log.warning(
f"Goodbye channel ID {goodbye_channel_id} not found or not text channel in guild {guild.id}"
)
return
# --- Determine reason for leaving ---
reason = "left"
entry_user = None
# Check audit log for kick or ban. We check last 2 minutes just in case of delays.
try:
# Check for ban first
async for entry in guild.audit_logs(
limit=1,
action=discord.AuditLogAction.ban,
after=datetime.now(timezone.utc) - timedelta(minutes=2),
):
if entry.target and entry.target.id == member.id:
reason = "banned"
entry_user = entry.user
break
# If not banned, check for kick
if reason == "left":
async for entry in guild.audit_logs(
limit=1,
action=discord.AuditLogAction.kick,
after=datetime.now(timezone.utc) - timedelta(minutes=2),
):
if entry.target and entry.target.id == member.id:
reason = "kicked"
entry_user = entry.user
break
except discord.Forbidden:
log.warning(
f"Missing 'View Audit Log' permissions in guild {guild.id} to determine member remove reason."
)
except Exception as e:
log.error(
f"Error checking audit log for {member.name} in {guild.id}: {e}"
)
# --- Format and send message ---
if reason == "left":
formatted_message = goodbye_message_template.format(
user=member.mention, # Might not be mentionable after leaving
username=member.name,
server=guild.name,
)
elif reason == "kicked":
formatted_message = f"**{member.name}** was kicked from the server"
if entry_user and entry_user != self.bot.user:
formatted_message += f" by **{entry_user.name}**"
formatted_message += "."
else: # banned
formatted_message = f"**{member.name}** was banned from the server"
if entry_user and entry_user != self.bot.user:
formatted_message += f" by **{entry_user.name}**"
formatted_message += "."
# Get current member count
member_count = guild.member_count or len(guild.members)
view = GoodbyeMessageView(member, formatted_message, member_count, reason)
await channel.send(view=view)
log.info(
f"Sent goodbye message for {member.name} in guild {guild.id} (Reason: {reason})"
)
except ValueError:
log.error(
f"Invalid goodbye_channel_id '{goodbye_channel_id_str}' configured for guild {guild.id}"
)
except discord.Forbidden:
log.error(
f"Missing permissions to send goodbye message in channel {goodbye_channel_id} for guild {guild.id}"
)
except Exception as e:
log.exception(f"Error sending goodbye message for guild {guild.id}: {e}")
@commands.command(
name="setwelcome",
help="Sets the welcome message and channel. Usage: `setwelcome #channel [message template]`",
)
@commands.has_permissions(administrator=True)
@commands.guild_only()
async def set_welcome(
self,
ctx: commands.Context,
channel: discord.TextChannel,
*,
message_template: str = "Welcome {user} to {server}!",
):
"""Sets the channel and template for welcome messages."""
guild_id = ctx.guild.id
key_channel = "welcome_channel_id"
key_message = "welcome_message"
# Use settings_manager.set_setting
success_channel = await settings_manager.set_setting(
guild_id, key_channel, str(channel.id)
)
success_message = await settings_manager.set_setting(
guild_id, key_message, message_template
)
if success_channel and success_message: # Both need to succeed
embed = discord.Embed(
title="✅ Welcome Messages Configured",
description=f"Welcome messages will now be sent to {channel.mention}",
color=discord.Color.green()
)
embed.add_field(
name="Message Template",
value=f"```\n{message_template}\n```",
inline=False
)
embed.add_field(
name="Available Variables",
value="`{user}` - Mentions the user\n`{username}` - User's name\n`{server}` - Server name",
inline=False
)
await ctx.send(embed=embed)
log.info(
f"Welcome settings updated for guild {guild_id} by {ctx.author.name}"
)
else:
await ctx.send("❌ Failed to save welcome settings. Check logs.")
log.error(f"Failed to save welcome settings for guild {guild_id}")
@commands.command(
name="disablewelcome", help="Disables welcome messages for this server."
)
@commands.has_permissions(administrator=True)
@commands.guild_only()
async def disable_welcome(self, ctx: commands.Context):
"""Disables welcome messages by removing the channel setting."""
guild_id = ctx.guild.id
key_channel = "welcome_channel_id"
key_message = "welcome_message" # Also clear the message template
# Use set_setting with None to delete the settings
success_channel = await settings_manager.set_setting(guild_id, key_channel, None)
success_message = await settings_manager.set_setting(guild_id, key_message, None)
if success_channel and success_message: # Both need to succeed
embed = discord.Embed(
title="✅ Welcome Messages Disabled",
description="Welcome messages have been disabled for this server.",
color=discord.Color.orange()
)
await ctx.send(embed=embed)
log.info(
f"Welcome messages disabled for guild {guild_id} by {ctx.author.name}"
)
else:
await ctx.send("❌ Failed to disable welcome messages. Check logs.")
log.error(f"Failed to disable welcome settings for guild {guild_id}")
@commands.command(
name="setgoodbye",
help="Sets the goodbye message and channel. Usage: `setgoodbye #channel [message template]`",
)
@commands.has_permissions(administrator=True)
@commands.guild_only()
async def set_goodbye(
self,
ctx: commands.Context,
channel: discord.TextChannel,
*,
message_template: str = "{username} has left the server.",
):
"""Sets the channel and template for goodbye messages."""
guild_id = ctx.guild.id
key_channel = "goodbye_channel_id"
key_message = "goodbye_message"
# Use settings_manager.set_setting
success_channel = await settings_manager.set_setting(
guild_id, key_channel, str(channel.id)
)
success_message = await settings_manager.set_setting(
guild_id, key_message, message_template
)
if success_channel and success_message: # Both need to succeed
embed = discord.Embed(
title="✅ Goodbye Messages Configured",
description=f"Goodbye messages will now be sent to {channel.mention}",
color=discord.Color.green()
)
embed.add_field(
name="Message Template",
value=f"```\n{message_template}\n```",
inline=False
)
embed.add_field(
name="Available Variables",
value="`{user}` - Mentions the user (may not work after leaving)\n`{username}` - User's name\n`{server}` - Server name",
inline=False
)
embed.add_field(
name="Note",
value="Kick and ban messages will override the template with automatic formatting.",
inline=False
)
await ctx.send(embed=embed)
log.info(
f"Goodbye settings updated for guild {guild_id} by {ctx.author.name}"
)
else:
await ctx.send("❌ Failed to save goodbye settings. Check logs.")
log.error(f"Failed to save goodbye settings for guild {guild_id}")
@commands.command(
name="disablegoodbye", help="Disables goodbye messages for this server."
)
@commands.has_permissions(administrator=True)
@commands.guild_only()
async def disable_goodbye(self, ctx: commands.Context):
"""Disables goodbye messages by removing the channel setting."""
guild_id = ctx.guild.id
key_channel = "goodbye_channel_id"
key_message = "goodbye_message"
# Use set_setting with None to delete the settings
success_channel = await settings_manager.set_setting(guild_id, key_channel, None)
success_message = await settings_manager.set_setting(guild_id, key_message, None)
if success_channel and success_message: # Both need to succeed
embed = discord.Embed(
title="✅ Goodbye Messages Disabled",
description="Goodbye messages have been disabled for this server.",
color=discord.Color.orange()
)
await ctx.send(embed=embed)
log.info(
f"Goodbye messages disabled for guild {guild_id} by {ctx.author.name}"
)
else:
await ctx.send("❌ Failed to disable goodbye messages. Check logs.")
log.error(f"Failed to disable goodbye settings for guild {guild_id}")
# --- Test Commands ---
@commands.group(
name="testmessage",
help="Test the welcome or goodbye messages.",
invoke_without_command=True,
)
@commands.has_permissions(administrator=True)
@commands.guild_only()
async def testmessage(self, ctx: commands.Context):
"""Shows help for the testmessage command group."""
await ctx.send_help(ctx.command)
@testmessage.command(name="welcome")
async def test_welcome(
self, ctx: commands.Context, member: Optional[discord.Member] = None
):
"""Simulates a member joining to test the welcome message."""
target_member = member or ctx.author
await self.on_member_join(target_member)
embed = discord.Embed(
title="🧪 Test Message Sent",
description=f"Simulated welcome message for {target_member.mention}. Check the configured welcome channel.",
color=discord.Color.blue()
)
await ctx.send(embed=embed, ephemeral=True)
@testmessage.command(name="goodbye")
async def test_goodbye(
self,
ctx: commands.Context,
reason: Literal["left", "kicked", "banned"] = "left",
member: Optional[discord.Member] = None,
):
"""Simulates a member leaving to test the goodbye message."""
target_member = member or ctx.author
guild = ctx.guild
# --- Fetch settings ---
goodbye_channel_id_str = await settings_manager.get_setting(
guild.id, "goodbye_channel_id"
)
goodbye_message_template = await settings_manager.get_setting(
guild.id, "goodbye_message", default="{username} has left the server."
)
if not goodbye_channel_id_str or goodbye_channel_id_str == "__NONE__":
embed = discord.Embed(
title="❌ Configuration Error",
description="Goodbye message channel is not configured.",
color=discord.Color.red()
)
await ctx.send(embed=embed, ephemeral=True)
return
try:
goodbye_channel_id = int(goodbye_channel_id_str)
channel = guild.get_channel(goodbye_channel_id)
if not channel or not isinstance(channel, discord.TextChannel):
embed = discord.Embed(
title="❌ Configuration Error",
description="Configured goodbye channel not found or is not a text channel.",
color=discord.Color.red()
)
await ctx.send(embed=embed, ephemeral=True)
return
# --- Format and send message based on simulated reason ---
if reason == "left":
formatted_message = goodbye_message_template.format(
user=target_member.mention,
username=target_member.name,
server=guild.name,
)
elif reason == "kicked":
formatted_message = (
f"**{target_member.name}** was kicked from the server by **{ctx.author.name}**."
)
else: # banned
formatted_message = (
f"**{target_member.name}** was banned from the server by **{ctx.author.name}**."
)
# Get current member count
member_count = guild.member_count or len(guild.members)
view = GoodbyeMessageView(target_member, formatted_message, member_count, reason)
await channel.send(view=view)
embed = discord.Embed(
title="🧪 Test Message Sent",
description=f"Simulated goodbye message for {target_member.mention} (Reason: {reason}). Check the configured goodbye channel.",
color=discord.Color.blue()
)
await ctx.send(embed=embed, ephemeral=True)
except ValueError:
embed = discord.Embed(
title="❌ Configuration Error",
description="Invalid goodbye channel ID configured.",
color=discord.Color.red()
)
await ctx.send(embed=embed, ephemeral=True)
except discord.Forbidden:
embed = discord.Embed(
title="❌ Permission Error",
description="I don't have permissions to send messages in the configured goodbye channel.",
color=discord.Color.red()
)
await ctx.send(embed=embed, ephemeral=True)
except Exception as e:
log.exception(
f"Error sending test goodbye message for guild {guild.id}: {e}"
)
embed = discord.Embed(
title="❌ Unexpected Error",
description="An unexpected error occurred. Check the logs for details.",
color=discord.Color.red()
)
await ctx.send(embed=embed, ephemeral=True)
# Error Handling for this Cog
async def cog_command_error(self, ctx: commands.Context, error: commands.CommandError):
"""Handles errors for all commands in this cog."""
if isinstance(error, commands.MissingPermissions):
embed = discord.Embed(
title="❌ Missing Permissions",
description="You need Administrator permissions to use this command.",
color=discord.Color.red()
)
await ctx.send(embed=embed, ephemeral=True)
elif isinstance(error, commands.BadArgument):
embed = discord.Embed(
title="❌ Invalid Argument",
description=f"Invalid argument provided. Check the command help: `{ctx.prefix}help {ctx.command.name}`",
color=discord.Color.red()
)
await ctx.send(embed=embed, ephemeral=True)
elif isinstance(error, commands.MissingRequiredArgument):
embed = discord.Embed(
title="❌ Missing Argument",
description=f"Missing required argument. Check the command help: `{ctx.prefix}help {ctx.command.name}`",
color=discord.Color.red()
)
await ctx.send(embed=embed, ephemeral=True)
elif isinstance(error, commands.NoPrivateMessage):
embed = discord.Embed(
title="❌ Server Only",
description="This command cannot be used in private messages.",
color=discord.Color.red()
)
await ctx.send(embed=embed, ephemeral=True)
else:
original_error = getattr(error, 'original', error)
log.error(
f"Unhandled error in WelcomeCog command '{ctx.command.name}': {original_error}"
)
embed = discord.Embed(
title="❌ Unexpected Error",
description="An unexpected error occurred. Please check the logs for details.",
color=discord.Color.red()
)
await ctx.send(embed=embed, ephemeral=True)
async def setup(bot: commands.Bot):
# Ensure bot has pools initialized before adding the cog
if (
not hasattr(bot, "pg_pool")
or not hasattr(bot, "redis")
or bot.pg_pool is None
or bot.redis is None
):
log.warning(
"Bot pools not initialized before loading WelcomeCog. Cog will not load."
)
return # Prevent loading if pools are missing
welcome_cog = WelcomeCog(bot)
await bot.add_cog(welcome_cog)
log.info("WelcomeCog loaded.")