Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 86 additions & 53 deletions bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,21 @@
from discord import app_commands
from discord.ext import commands
from dotenv import load_dotenv

from utils.config import Config

# Load environment variables
load_dotenv()

# Configure logging
logging.basicConfig(
level=getattr(logging, os.getenv('LOG_LEVEL', 'INFO')),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('bot.log'),
logging.StreamHandler()
]
level=getattr(logging, os.getenv("LOG_LEVEL", "INFO")),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[logging.FileHandler("bot.log"), logging.StreamHandler()],
)
logger = logging.getLogger(__name__)


class Fun2OoshBot(commands.Bot):
"""Main bot class for Eigen Bot."""

Expand All @@ -41,23 +40,26 @@ def __init__(self, config: Config):

# Disable the built-in help_command so a custom help cog can register `?helpmenu` and `/help`
super().__init__(
command_prefix='?',
command_prefix="?",
intents=intents,
help_command=None,
# SECURITY: Prevent mass-mention exploits caused by echoing user content.
# Even if the bot has Administrator / Mention Everyone, this blocks @everyone/@here and role pings.
allowed_mentions=discord.AllowedMentions(everyone=False, roles=False, users=True, replied_user=False),
allowed_mentions=discord.AllowedMentions(
everyone=False, roles=False, users=True, replied_user=False
),
)

self.start_time = discord.utils.utcnow()
self.config = config
# Discover available cog modules from the cogs directory
from pathlib import Path
cogs_dir = Path(__file__).resolve().parent / 'cogs'

cogs_dir = Path(__file__).resolve().parent / "cogs"
self.available_cogs = []
if cogs_dir.exists() and cogs_dir.is_dir():
for p in sorted(cogs_dir.iterdir()):
if p.suffix == '.py' and p.stem != '__init__':
if p.suffix == ".py" and p.stem != "__init__":
self.available_cogs.append(p.stem)
logger.info(f"Available cogs discovered: {self.available_cogs}")

Expand All @@ -66,54 +68,55 @@ async def setup_hook(self) -> None:
# Initialize CodeBuddy database
try:
from utils.codebuddy_database import init_db

await init_db()
logger.info("Initialized CodeBuddy database")
except Exception as e:
logger.error(f"Failed to initialize CodeBuddy database: {e}")

# Load core cogs
core_cogs = [
'cogs.misc',
'cogs.admin',
'cogs.tickets',
"cogs.misc",
"cogs.admin",
"cogs.tickets",
]

for ext in core_cogs:
try:
await self.load_extension(ext)
logger.info(f'Loaded {ext}')
logger.info(f"Loaded {ext}")
except Exception as e:
logger.error(f'Failed to load {ext}: {e}')
logger.error(f"Failed to load {ext}: {e}")

# Load feature cogs (new/renamed)
feature_cogs = [
'cogs.tags',
'cogs.fun',
'cogs.starboard',
'cogs.help',
'cogs.community',
'cogs.utility_extra',
'cogs.afk',
'cogs.birthday',
'cogs.bump_leaderboard',
'cogs.suggestions',
'cogs.codebuddy_quiz',
'cogs.codebuddy_leaderboard',
'cogs.codebuddy_help',
'cogs.counting',
'cogs.tod',
'cogs.daily_quests',
'cogs.staff_applications',
'cogs.tts',
'cogs.chowkidar'
"cogs.tags",
"cogs.fun",
"cogs.starboard",
"cogs.help",
"cogs.community",
"cogs.utility_extra",
"cogs.afk",
"cogs.birthday",
"cogs.bump_leaderboard",
"cogs.suggestions",
"cogs.codebuddy_quiz",
"cogs.codebuddy_leaderboard",
"cogs.codebuddy_help",
"cogs.counting",
"cogs.tod",
"cogs.daily_quests",
"cogs.staff_applications",
"cogs.tts",
"cogs.chowkidar",
]

for ext in feature_cogs:
try:
await self.load_extension(ext)
logger.info(f'Loaded {ext}')
logger.info(f"Loaded {ext}")
except Exception as e:
logger.error(f'Failed to load {ext}: {e}')
logger.error(f"Failed to load {ext}: {e}")

# Load modmail cog
# try:
Expand All @@ -132,19 +135,27 @@ async def setup_hook(self) -> None:
self.tree.copy_global_to(guild=guild)
synced = await self.tree.sync(guild=guild)

logger.info(f"✅ Synced {len(synced)} slash commands to guild {guild_id}")
logger.info(f"📊 Guild Command Slots: {len(synced)}/100 used ({100 - len(synced)} remaining)")
logger.info(
f"✅ Synced {len(synced)} slash commands to guild {guild_id}"
)
logger.info(
f"📊 Guild Command Slots: {len(synced)}/100 used ({100 - len(synced)} remaining)"
)

command_names = [cmd.name for cmd in synced]
logger.info(f"📝 Synced commands for {guild_id}: {', '.join(command_names)}")
logger.info(
f"📝 Synced commands for {guild_id}: {', '.join(command_names)}"
)
else:
synced = await self.tree.sync()
logger.info(f"✅ Synced {len(synced)} slash commands globally")
logger.info(f"📊 Global Command Slots: {len(synced)}/100 used ({100 - len(synced)} remaining)")
logger.info(
f"📊 Global Command Slots: {len(synced)}/100 used ({100 - len(synced)} remaining)"
)

command_names = [cmd.name for cmd in synced]
logger.info(f"📝 Synced commands: {', '.join(command_names)}")

except Exception as e:
logger.error(f"❌ Failed to sync slash commands: {e}")

Expand All @@ -155,17 +166,19 @@ async def setup_hook(self) -> None:
async def on_ready(self):
"""Called when the bot is ready."""
if self.user:
logger.info(f'Logged in as {self.user} (ID: {self.user.id})')
logger.info(f"Logged in as {self.user} (ID: {self.user.id})")
else:
logger.info('Bot logged in but user is None')
logger.info(f'Connected to {len(self.guilds)} guilds')
logger.info("Bot logged in but user is None")
logger.info(f"Connected to {len(self.guilds)} guilds")

# Set presence
await self.change_presence(
activity=discord.Game(name="?helpmenu /help | Made by YC45")
)

async def on_command_error(self, ctx: commands.Context, error: commands.CommandError):
async def on_command_error(
self, ctx: commands.Context, error: commands.CommandError
):
"""Handle command errors."""
# Silence unknown/prefix-not-found commands
if isinstance(error, commands.CommandNotFound):
Expand All @@ -189,7 +202,12 @@ async def _safe_ctx_send(message: str) -> None:
return
await interaction.followup.send(message, ephemeral=True)
return
except (discord.NotFound, discord.HTTPException, discord.Forbidden, RuntimeError):
except (
discord.NotFound,
discord.HTTPException,
discord.Forbidden,
RuntimeError,
):
# Fall back to a normal channel send.
pass
except Exception:
Expand All @@ -202,7 +220,9 @@ async def _safe_ctx_send(message: str) -> None:
return

if isinstance(error, commands.CommandOnCooldown):
await _safe_ctx_send(f"This command is on cooldown. Try again in {error.retry_after:.2f} seconds.")
await _safe_ctx_send(
f"This command is on cooldown. Try again in {error.retry_after:.2f} seconds."
)
elif isinstance(error, commands.MissingPermissions):
await _safe_ctx_send("You don't have permission to use this command.")
elif isinstance(error, commands.BadArgument):
Expand All @@ -211,7 +231,9 @@ async def _safe_ctx_send(message: str) -> None:
logger.error(f"Command error: {error}")
await _safe_ctx_send("An error occurred while processing your command.")

async def on_app_command_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError):
async def on_app_command_error(
self, interaction: discord.Interaction, error: app_commands.AppCommandError
):
"""Handle slash command errors."""
# Silence unknown slash/app commands.
# `app_commands` doesn't expose a stable CommandNotFound across versions, so be conservative.
Expand All @@ -220,7 +242,7 @@ async def on_app_command_error(self, interaction: discord.Interaction, error: ap
if isinstance(error, app_commands.CommandOnCooldown):
await interaction.response.send_message(
f"This command is on cooldown. Try again in {error.retry_after:.2f} seconds.",
ephemeral=True
ephemeral=True,
)
else:
# For other app command errors, log and try to respond once
Expand All @@ -229,12 +251,13 @@ async def on_app_command_error(self, interaction: discord.Interaction, error: ap
if not interaction.response.is_done():
await interaction.response.send_message(
"An error occurred while processing your command.",
ephemeral=True
ephemeral=True,
)
except Exception:
# If sending fails, silently ignore to avoid noisy errors for unknown commands
return


async def main():
"""Main function to run the bot."""
config = Config()
Expand All @@ -248,11 +271,21 @@ async def main():
try:
await bot.start(config.discord_token)
except KeyboardInterrupt:
logger.info("Bot shutdown requested.")
logger.info("Bot shutdown requested (KeyboardInterrupt).")
except asyncio.CancelledError:
# Occurs during event loop cancellation / shutdown sequences (e.g. Ctrl-C)
logger.info(
"Bot start cancelled (asyncio.CancelledError). Shutting down gracefully."
)
except Exception as e:
logger.error(f"Bot encountered an error: {e}")
finally:
await bot.close()

if __name__ == '__main__':
asyncio.run(main())

if __name__ == "__main__":
try:
asyncio.run(main())
except (KeyboardInterrupt, asyncio.CancelledError):
# Graceful shutdown requested (Ctrl-C or event loop cancellation).
logger.info("Shutdown requested; exiting.")
36 changes: 30 additions & 6 deletions cogs/counting.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@

from utils.codebuddy_database import (
DB_PATH,
MAX_SAVE_UNITS,
add_guild_save_units,
get_guild_daily_award_info,
get_guild_save_units,
get_user_save_units,
increment_guild_daily_count,
Expand Down Expand Up @@ -45,16 +47,31 @@ async def _reaction_worker(self) -> None:
# A small delay between reaction requests keeps us under the common reaction route limits.
# Reactions may appear slightly delayed, but they will still be added.
while True:
message, emoji = await self._reaction_queue.get()
try:
message, emoji = await self._reaction_queue.get()
except asyncio.CancelledError:
# Task cancelled (bot shutdown); exit gracefully.
break

try:
try:
await message.add_reaction(emoji)
except Exception:
pass
await asyncio.sleep(0.35)
try:
await asyncio.sleep(0.35)
except asyncio.CancelledError:
# Task cancelled during sleep; still perform cleanup and exit loop.
self._pending_reactions.discard((message.id, emoji))
self._reaction_queue.task_done()
break
finally:
self._pending_reactions.discard((message.id, emoji))
self._reaction_queue.task_done()
try:
self._reaction_queue.task_done()
except Exception:
# Ignore if task_done called multiple times or queue already closed.
pass

def _enqueue_reaction(self, message: discord.Message, emoji: str) -> None:
key = (message.id, emoji)
Expand Down Expand Up @@ -577,9 +594,11 @@ async def on_message(self, message):
# Guild daily counting: increment the per-guild daily counter and award
# a server save for every 10 valid counts (counts may come from multiple users).
try:
awarded, new_daily_count = await increment_guild_daily_count(
message.guild.id
)
(
awarded,
new_daily_count,
reason,
) = await increment_guild_daily_count(message.guild.id)
if awarded:
# Try to get current server save units for display (best-effort).
try:
Expand All @@ -598,6 +617,11 @@ async def on_message(self, message):
await message.channel.send(
f"Server reached **{new_daily_count}** counting numbers today — awarded **1.0** server save!"
)
else:
# Silent on non-award situations to avoid spamming the counting channel.
# We maintain internal state and will award again when conditions are met.
pass

except Exception:
# Best-effort only; do not break counting if this fails.
pass
Expand Down
Loading
Loading