# bot_discord.py import discord from discord import app_commands from discord.ext import commands, tasks import importlib import cmd_discord import json import os import globals from globals import logger import modules import modules.utility from modules.db import log_message, lookup_user, log_bot_event, log_discord_activity primary_guild = globals.constants.primary_discord_guild() class DiscordBot(commands.Bot): def __init__(self): super().__init__(command_prefix="!", intents=discord.Intents.all()) self.remove_command("help") # Remove built-in help function self.config = globals.constants.config_data self.log = globals.logger # Use the logging function from bots.py self.db_conn = None # We'll set this later self.help_data = None # We'll set this later logger.info("Discord bot initiated") # async def sync_slash_commands(self): # """Syncs slash commands for the bot.""" # await self.wait_until_ready() # try: # await self.tree.sync() # primary_guild = discord.Object(id=int(self.config["discord_guilds"][0])) # await self.tree.sync(guild=primary_guild) # self.log("Discord slash commands synced.") # except Exception as e: # self.log(f"Unable to sync Discord slash commands: {e}", "ERROR") def set_db_connection(self, db_conn): """ Store the DB connection in the bot so commands can use it. """ self.db_conn = db_conn async def setup_hook(self): # This is an async-ready function you can override in discord.py 2.0. for filename in os.listdir("cmd_discord"): if filename.endswith(".py") and filename != "__init__.py": cog_name = f"cmd_discord.{filename[:-3]}" await self.load_extension(cog_name) # now we can await it # Log which cogs got loaded short_name = filename[:-3] logger.debug(f"Loaded Discord command cog '{short_name}'") logger.info("All Discord command cogs loaded successfully.") # Now that cogs are all loaded, run any help file initialization: help_json_path = "dictionary/help_discord.json" modules.utility.initialize_help_data( bot=self, help_json_path=help_json_path, is_discord=True ) @commands.command(name="reload") @commands.is_owner() async def reload(ctx, cog_name: str): """Reloads a specific cog without restarting the bot.""" try: await ctx.bot.unload_extension(f"cmd_discord.{cog_name}") await ctx.bot.load_extension(f"cmd_discord.{cog_name}") await ctx.reply(f"✅ Reloaded `{cog_name}` successfully!") logger.info(f"Successfully reloaded the command cog `{cog_name}`") except Exception as e: await ctx.reply(f"❌ Error reloading `{cog_name}`: {e}") logger.error(f"Failed to reload the command cog `{cog_name}`") async def on_message(self, message): if message.guild: guild_name = message.guild.name channel_name = message.channel.name else: guild_name = "DM" channel_name = "Direct Message" logger.debug(f"Message detected by '{message.author.name}' in '{guild_name}' - #'{channel_name}'") try: is_bot = message.author.bot user_id = str(message.author.id) user_name = message.author.name display_name = message.author.display_name platform_str = f"discord-{guild_name}" channel_str = channel_name # Track user activity modules.utility.track_user_activity( db_conn=self.db_conn, platform="discord", user_id=user_id, username=user_name, display_name=display_name, user_is_bot=is_bot ) attachments = ", ".join(a.url for a in message.attachments) if message.attachments else "" log_message( db_conn=self.db_conn, identifier=user_id, identifier_type="discord_user_id", message_content=message.content or "", platform=platform_str, channel=channel_str, attachments=attachments, platform_message_id=str(message.id) # Include Discord message ID ) except Exception as e: logger.warning(f"... UUI lookup failed: {e}") pass try: await self.process_commands(message) logger.debug(f"Command processing complete") except Exception as e: logger.error(f"Command processing failed: {e}") # async def on_reaction_add(self, reaction, user): # if user.bot: # return # Ignore bot reactions # guild_name = reaction.message.guild.name if reaction.message.guild else "DM" # channel_name = reaction.message.channel.name if hasattr(reaction.message.channel, "name") else "Direct Message" # logger.debug(f"Reaction added by '{user.name}' on message '{reaction.message.id}' in '{guild_name}' - #{channel_name}") # modules.utility.track_user_activity( # db_conn=self.db_conn, # platform="discord", # user_id=str(user.id), # username=user.name, # display_name=user.display_name, # user_is_bot=user.bot # ) # platform_str = f"discord-{guild_name}" # channel_str = channel_name # log_message( # db_conn=self.db_conn, # identifier=str(user.id), # identifier_type="discord_user_id", # message_content=f"Reaction Added: {reaction.emoji} on Message ID: {reaction.message.id}", # platform=platform_str, # channel=channel_str # ) # async def on_reaction_remove(self, reaction, user): # if user.bot: # return # Ignore bot reactions # guild_name = reaction.message.guild.name if reaction.message.guild else "DM" # channel_name = reaction.message.channel.name if hasattr(reaction.message.channel, "name") else "Direct Message" # logger.debug(f"Reaction removed by '{user.name}' on message '{reaction.message.id}' in '{guild_name}' - #{channel_name}") # modules.utility.track_user_activity( # db_conn=self.db_conn, # platform="discord", # user_id=str(user.id), # username=user.name, # display_name=user.display_name, # user_is_bot=user.bot # ) # platform_str = f"discord-{guild_name}" # channel_str = channel_name # log_message( # db_conn=self.db_conn, # identifier=str(user.id), # identifier_type="discord_user_id", # message_content=f"Reaction Removed: {reaction.emoji} on Message ID: {reaction.message.id}", # platform=platform_str, # channel=channel_str # ) # async def on_message_edit(self, before, after): # if before.author.bot: # return # Ignore bot edits # guild_name = before.guild.name if before.guild else "DM" # channel_name = before.channel.name if hasattr(before.channel, "name") else "Direct Message" # logger.debug(f"Message edited by '{before.author.name}' in '{guild_name}' - #{channel_name}") # logger.debug(f"Before: {before.content}\nAfter: {after.content}") # modules.utility.track_user_activity( # db_conn=self.db_conn, # platform="discord", # user_id=str(before.author.id), # username=before.author.name, # display_name=before.author.display_name, # user_is_bot=before.author.bot # ) # platform_str = f"discord-{guild_name}" # channel_str = channel_name # log_message( # db_conn=self.db_conn, # identifier=str(before.author.id), # identifier_type="discord_user_id", # message_content=f"Message Edited:\nBefore: {before.content}\nAfter: {after.content}", # platform=platform_str, # channel=channel_str # ) # async def on_thread_create(self, thread): # logger.debug(f"Thread '{thread.name}' created in #{thread.parent.name}") # modules.utility.track_user_activity( # db_conn=self.db_conn, # platform="discord", # user_id=str(thread.owner_id), # username=thread.owner.name if thread.owner else "Unknown", # display_name=thread.owner.display_name if thread.owner else "Unknown", # user_is_bot=False # ) # log_message( # db_conn=self.db_conn, # identifier=str(thread.owner_id), # identifier_type="discord_user_id", # message_content=f"Thread Created: {thread.name} in #{thread.parent.name}", # platform=f"discord-{thread.guild.name}", # channel=thread.parent.name # ) # async def on_thread_update(self, before, after): # logger.debug(f"Thread updated: '{before.name}' -> '{after.name}'") # log_message( # db_conn=self.db_conn, # identifier=str(before.owner_id), # identifier_type="discord_user_id", # message_content=f"Thread Updated: '{before.name}' -> '{after.name}'", # platform=f"discord-{before.guild.name}", # channel=before.parent.name # ) # async def on_thread_delete(self, thread): # logger.debug(f"Thread '{thread.name}' deleted") # log_message( # db_conn=self.db_conn, # identifier=str(thread.owner_id), # identifier_type="discord_user_id", # message_content=f"Thread Deleted: {thread.name}", # platform=f"discord-{thread.guild.name}", # channel=thread.parent.name # ) def load_bot_settings(self): """Loads bot activity settings from JSON file.""" try: with open("settings/discord_bot_settings.json", "r") as file: return json.load(file) except Exception as e: self.log.error(f"Failed to load settings: {e}") return { "activity_mode": 0, "static_activity": {"type": "Playing", "name": "with my commands!"}, "rotating_activities": [], "dynamic_activities": {}, "rotation_interval": 600 } async def on_command(self, ctx): """Logs every command execution at DEBUG level.""" _cmd_args = str(ctx.message.content).split(" ")[1:] channel_name = "Direct Message" if "Direct Message with" in str(ctx.channel) else ctx.channel logger.debug(f"Command '{ctx.command}' (Discord) initiated by {ctx.author} in #{channel_name}") if len(_cmd_args) > 1: logger.debug(f"!{ctx.command} arguments: {_cmd_args}") async def on_interaction(interaction: discord.Interaction): # Only log application command (slash command) interactions. if interaction.type == discord.InteractionType.application_command: # Get the command name from the interaction data. command_name = interaction.data.get("name") # Get the options (arguments) if any. options = interaction.data.get("options", []) # Convert options to a list of values or key-value pairs. option_values = [f'{opt.get("name")}: {opt.get("value")}' for opt in options] # Determine the channel name (or DM). if interaction.channel and hasattr(interaction.channel, "name"): channel_name = interaction.channel.name else: channel_name = "Direct Message" logger.debug(f"Command '{command_name}' (Discord) initiated by {interaction.user} in #{channel_name}") if option_values: logger.debug(f"Command '{command_name}' arguments: {option_values}") async def on_ready(self): """Runs when the bot successfully logs in.""" # Load activity settings self.settings = self.load_bot_settings() # Set initial bot activity await self.update_activity() # Sync Slash Commands try: # Sync slash commands globally #await self.tree.sync() #logger.info("Discord slash commands synced.") num_guilds = len(self.config["discord_guilds"]) cmd_tree_result = (await self.tree.sync(guild=primary_guild["object"])) command_names = [command.name for command in cmd_tree_result] if cmd_tree_result else None if primary_guild["id"]: try: guild_info = await modules.utility.get_guild_info(self, primary_guild["id"]) primary_guild_name = guild_info["name"] except Exception as e: primary_guild_name = f"{primary_guild["id"]}" logger.error(f"Guild lookup failed: {e}") _log_message = f"{num_guilds} guilds (global)" if num_guilds > 1 else f"guild: {primary_guild_name}" logger.info(f"Discord slash commands force synced to {_log_message}") logger.info(f"Discord slash commands that got synced: {command_names}") else: logger.info("Discord commands synced globally.") except Exception as e: logger.error(f"Unable to sync Discord slash commands: {e}") # Log successful bot startup logger.info(f"Discord bot is online as {self.user}") log_bot_event(self.db_conn, "DISCORD_RECONNECTED", "Discord bot logged in.") async def on_disconnect(self): logger.warning("Discord bot has lost connection!") log_bot_event(self.db_conn, "DISCORD_DISCONNECTED", "Discord bot lost connection.") async def update_activity(self): """Sets the bot's activity based on settings.""" mode = self.settings.get("activity_mode", 0) # Stop rotating activity loop if it's running if self.change_rotating_activity.is_running(): self.change_rotating_activity.stop() if mode == 0: # Disable activity await self.change_presence(activity=None) self.log.debug("Activity disabled") elif mode == 1: # Static activity activity_data = self.settings.get("static_activity", {}) if activity_data: activity = self.get_activity(activity_data.get("type"), activity_data.get("name")) await self.change_presence(activity=activity) self.log.debug(f"Static activity set: {activity_data['type']} {activity_data['name']}") else: await self.change_presence(activity=None) self.log.debug("No static activity defined") elif mode == 2: # Rotating activity activities = self.settings.get("rotating_activities", []) if activities: self.change_rotating_activity.change_interval(seconds=self.settings.get("rotation_interval", 300)) self.change_rotating_activity.start() self.log.debug("Rotating activity mode enabled") else: self.log.info("No rotating activities defined, falling back to static.") await self.update_activity_static() elif mode == 3: # Dynamic activity with fallback if not await self.set_dynamic_activity(): self.log.info("Dynamic activity unavailable, falling back.") # Fallback to rotating or static if self.settings.get("rotating_activities"): self.change_rotating_activity.start() self.log.debug("Falling back to rotating activity.") else: await self.update_activity_static() else: self.log.warning("Invalid activity mode, defaulting to disabled.") await self.change_presence(activity=None) async def update_activity_static(self): """Fallback to static activity if available.""" activity_data = self.settings.get("static_activity", {}) if activity_data: activity = self.get_activity(activity_data.get("type"), activity_data.get("name")) await self.change_presence(activity=activity) self.log.debug(f"Static activity set: {activity_data['type']} {activity_data['name']}") else: await self.change_presence(activity=None) self.log.debug("No static activity defined, activity disabled.") @tasks.loop(seconds=300) # Default to 5 minutes async def change_rotating_activity(self): """Rotates activities every set interval.""" activities = self.settings.get("rotating_activities", []) if not activities: self.log.info("No rotating activities available, stopping rotation.") self.change_rotating_activity.stop() return # Rotate activity activity_data = activities.pop(0) activities.append(activity_data) # Move to the end of the list activity = self.get_activity(activity_data.get("type"), activity_data.get("name")) await self.change_presence(activity=activity) self.log.debug(f"Rotating activity: {activity_data['type']} {activity_data['name']}") async def set_dynamic_activity(self): """Sets a dynamic activity based on external conditions.""" twitch_live = await modules.utility.is_channel_live(self) if twitch_live: activity_data = self.settings["dynamic_activities"].get("twitch_live") else: activity_data = self.settings["dynamic_activities"].get("default_idle") if activity_data: activity = self.get_activity(activity_data.get("type"), activity_data.get("name"), activity_data.get("url")) await self.change_presence(activity=activity) self.log.debug(f"Dynamic activity set: {activity_data['type']} {activity_data['name']}") return True # Dynamic activity was set return False # No dynamic activity available def get_activity(self, activity_type, name, url=None): """Returns a discord activity object based on type, including support for Custom Status.""" activity_map = { "Playing": discord.Game(name=name), "Streaming": discord.Streaming(name=name, url=url or "https://twitch.tv/OokamiKunTV"), "Listening": discord.Activity(type=discord.ActivityType.listening, name=name), "Watching": discord.Activity(type=discord.ActivityType.watching, name=name), "Custom": discord.CustomActivity(name=name) } return activity_map.get(activity_type, discord.Game(name="around in Discord")) async def on_voice_state_update(self, member, before, after): """ Tracks user joins, leaves, mutes, deafens, streams, and voice channel moves. """ guild_id = str(member.guild.id) discord_user_id = str(member.id) voice_channel = after.channel.name if after.channel else before.channel.name if before.channel else None # Ensure user exists in the UUI system user_uuid = modules.db.lookup_user(self.db_conn, identifier=discord_user_id, identifier_type="discord_user_id", target_identifier="UUID") if not user_uuid: logger.info(f"User {member.name} ({discord_user_id}) not found in 'users'. Attempting to add...") modules.utility.track_user_activity( db_conn=self.db_conn, platform="discord", user_id=discord_user_id, username=member.name, display_name=member.display_name, user_is_bot=member.bot ) user_uuid= modules.db.lookup_user(self.db_conn, identifier=discord_user_id, identifier_type="discord_user_id", target_identifier="UUID") if not user_uuid: logger.warning(f"Failed to associate {member.name} ({discord_user_id}) with a UUID. Skipping activity log.") return # Prevent logging with invalid UUID if user_uuid: logger.info(f"Successfully added {member.name} ({discord_user_id}) to the UUI database.") # Detect join and leave events if before.channel is None and after.channel is not None: modules.db.log_discord_activity(self.db_conn, guild_id, discord_user_id, "JOIN", after.channel.name) elif before.channel is not None and after.channel is None: modules.db.log_discord_activity(self.db_conn, guild_id, discord_user_id, "LEAVE", before.channel.name) # Detect VC moves (self/moved) if before.channel and after.channel and before.channel != after.channel: move_detail = f"{before.channel.name} -> {after.channel.name}" modules.db.log_discord_activity(self.db_conn, guild_id, discord_user_id, "VC_MOVE", after.channel.name, move_detail) # Detect mute/unmute if before.self_mute != after.self_mute: mute_action = "MUTE" if after.self_mute else "UNMUTE" modules.db.log_discord_activity(self.db_conn, guild_id, discord_user_id, mute_action, voice_channel) # Detect deafen/undeafen if before.self_deaf != after.self_deaf: deaf_action = "DEAFEN" if after.self_deaf else "UNDEAFEN" modules.db.log_discord_activity(self.db_conn, guild_id, discord_user_id, deaf_action, voice_channel) # Detect streaming if before.self_stream != after.self_stream: stream_action = "STREAM_START" if after.self_stream else "STREAM_STOP" modules.db.log_discord_activity(self.db_conn, guild_id, discord_user_id, stream_action, voice_channel) # Detect camera usage if before.self_video != after.self_video: camera_action = "CAMERA_ON" if after.self_video else "CAMERA_OFF" modules.db.log_discord_activity(self.db_conn, guild_id, discord_user_id, camera_action, voice_channel) async def on_presence_update(self, before, after): """ Detects when a user starts or stops a game, Spotify, or Discord activity. Ensures the activity is logged using the correct UUID from the UUI system. """ if not after.guild: # Ensure it's in a guild (server) return if before.activities == after.activities and before.status == after.status: # No real changes, skip return guild_id = str(after.guild.id) discord_user_id = str(after.id) # Ensure user exists in the UUI system user_uuid = modules.db.lookup_user( self.db_conn, identifier=discord_user_id, identifier_type="discord_user_id", target_identifier="UUID" ) if not user_uuid: logger.info(f"User {after.name} ({discord_user_id}) not found in 'users'. Attempting to add...") modules.utility.track_user_activity( db_conn=self.db_conn, platform="discord", user_id=discord_user_id, username=after.name, display_name=after.display_name, user_is_bot=after.bot ) user_uuid = modules.db.lookup_user(self.db_conn, identifier=discord_user_id, identifier_type="discord_user_id", target_identifier="UUID") if not user_uuid: logger.error(f"Failed to associate {after.name} ({discord_user_id}) with a UUID. Skipping activity log.") return if user_uuid: logger.info(f"Successfully added {after.name} ({discord_user_id}) to the UUI database.") # Check all activities new_activity = None for n_activity in after.activities: if isinstance(n_activity, discord.Game): new_activity = ("GAME_START", n_activity.name) elif isinstance(n_activity, discord.Spotify): # Get artist name(s) and format as "{artist_name} - {song_title}" artist_name = ", ".join(n_activity.artists) song_name = n_activity.title spotify_detail = f"{artist_name} - {song_name}" new_activity = ("LISTENING_SPOTIFY", spotify_detail) elif isinstance(n_activity, discord.Streaming): new_activity = ("STREAM_START", n_activity.game or "Sharing screen") # Check all activities old_activity = None for o_activity in before.activities: if isinstance(o_activity, discord.Game): old_activity = ("GAME_STOP", o_activity.name) # IGNORE OLD SPOTIFY EVENTS elif isinstance(o_activity, discord.Streaming): old_activity = ("STREAM_STOP", o_activity.game or "Sharing screen") if new_activity: modules.db.log_discord_activity(self.db_conn, guild_id, discord_user_id, new_activity[0], None, new_activity[1]) if old_activity: modules.db.log_discord_activity(self.db_conn, guild_id, discord_user_id, old_activity[0], None, old_activity[1]) # async def start_account_linking(self, interaction: discord.Interaction): # """Starts the linking process by generating a link code and displaying instructions.""" # user_id = str(interaction.user.id) # # Check if the user already has a linked account # user_data = modules.db.lookup_user(self.db_conn, self.log, identifier=user_id, identifier_type="discord_user_id") # if user_data and user_data["twitch_user_id"]: # link_date = user_data["datetime_linked"] # await interaction.response.send_message( # f"Your Discord account is already linked to Twitch user **{user_data['twitch_user_display_name']}** " # f"(linked on {link_date}). You must remove the link before linking another account.", ephemeral=True) # return # # Generate a unique link code # link_code = modules.utility.generate_link_code() # modules.db.run_db_operation( # self.db_conn, "write", # "INSERT INTO link_codes (DISCORD_USER_ID, LINK_CODE) VALUES (?, ?)", # (user_id, link_code), self.log # ) # # Show the user the link modal # await interaction.response.send_message( # f"To link your Twitch account, post the following message in Twitch chat:\n" # f"`!acc_link {link_code}`\n\n" # f"Then, return here and click 'Done'.", ephemeral=True # ) # async def finalize_account_linking(self, interaction: discord.Interaction): # """Finalizes the linking process by merging duplicate UUIDs.""" # from modules import db # user_id = str(interaction.user.id) # # Fetch the updated user info # user_data = modules.db.lookup_user(self.db_conn, self.log, identifier=user_id, identifier_type="discord_user_id") # if not user_data or not user_data["twitch_user_id"]: # await interaction.response.send_message( # "No linked Twitch account found. Please complete the linking process first.", ephemeral=True) # return # discord_uuid = user_data["UUID"] # twitch_uuid = modules.db.lookup_user(self.db_conn, self.log, identifier=user_data["twitch_user_id"], identifier_type="twitch_user_id")["UUID"] # if discord_uuid == twitch_uuid: # await interaction.response.send_message("Your accounts are already fully linked.", ephemeral=True) # return # # Merge all records from `twitch_uuid` into `discord_uuid` # db.merge_uuid_data(self.db_conn, self.log, old_uuid=twitch_uuid, new_uuid=discord_uuid) # # Delete the old Twitch UUID entry # db.run_db_operation(self.db_conn, "write", "DELETE FROM users WHERE UUID = ?", (twitch_uuid,), self.log) # # Confirm the final linking # await interaction.response.send_message("Your Twitch and Discord accounts are now fully linked.", ephemeral=True) async def run(self, token): try: await super().start(token) except Exception as e: logger.critical(f"Discord bot error: {e}")