From 2356bfb927f3e1c84dc5aab3f9485ea6051f1c04 Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Tue, 24 Jun 2025 16:09:09 +0100 Subject: [PATCH 01/23] Initial commit for the new tools page and a ghetto spypet feature WIP --- bot/commands/util.py | 53 +- bot/controller.py | 44 +- bot/tools/__init__.py | 1 + bot/tools/spypet.py | 319 +++++++++++++ data/gui_theme-light.json | 27 ++ data/icons/play-solid.png | Bin 0 -> 39477 bytes data/icons/rotate-left-solid.png | Bin 0 -> 52527 bytes data/icons/stop-solid.png | Bin 0 -> 22761 bytes gui/components/__init__.py | 3 +- gui/components/rounded_frame.py | 29 +- gui/components/settings/rich_presence.py | 2 +- gui/components/sidebar.py | 8 +- gui/components/tool_page.py | 52 ++ gui/helpers/images.py | 51 +- gui/main.py | 14 +- gui/pages/__init__.py | 3 +- gui/pages/tools/__init__.py | 4 + gui/pages/tools/message_logger_page.py | 12 + gui/pages/tools/spypet_page.py | 584 +++++++++++++++++++++++ gui/pages/tools/tools.py | 70 +++ gui/pages/tools/user_lookup_page.py | 96 ++++ utils/startup_check.py | 3 +- 22 files changed, 1347 insertions(+), 28 deletions(-) create mode 100644 bot/tools/__init__.py create mode 100644 bot/tools/spypet.py create mode 100644 data/gui_theme-light.json create mode 100644 data/icons/play-solid.png create mode 100644 data/icons/rotate-left-solid.png create mode 100644 data/icons/stop-solid.png create mode 100644 gui/components/tool_page.py create mode 100644 gui/pages/tools/__init__.py create mode 100644 gui/pages/tools/message_logger_page.py create mode 100644 gui/pages/tools/spypet_page.py create mode 100644 gui/pages/tools/tools.py create mode 100644 gui/pages/tools/user_lookup_page.py diff --git a/bot/commands/util.py b/bot/commands/util.py index 9fd7d80..64fdd93 100644 --- a/bot/commands/util.py +++ b/bot/commands/util.py @@ -350,7 +350,7 @@ async def spypet(self, ctx, member_id: int): mutual_guilds = [guild for guild in self.bot.guilds if guild.get_member(member_id)] data = {} tasks = [] - sem = asyncio.Semaphore(15) + sem = asyncio.Semaphore(10) stop_event = asyncio.Event() last_saved_count = 0 @@ -363,6 +363,7 @@ def _save_data(): if current_count == last_saved_count: return last_saved_count = current_count + with open(files.get_application_support() + "/data/spypet.json", "w") as f: json.dump(data, f, indent=4) console.print_info(f"Auto-saved {current_count} messages.") @@ -399,15 +400,18 @@ async def _fetch_context_channel(channel): latest_msg = [msg async for msg in channel.history(limit=1)][0] context = await self.bot.get_context(latest_msg) + console.success(f"Got context for {channel.guild.name} - {channel.name}") + return context.channel except Exception as e: if "429" in str(e): + console.error(f"Rate limited while fetching context for {channel.guild.name} - {channel.name}") await asyncio.sleep(5) return await _fetch_context_channel(channel) return None - async def _get_messages(channel, delay=0.25): + async def _get_messages(channel, delay=0.25, oldest_first=False): async with sem: try: await asyncio.sleep(delay) @@ -418,7 +422,8 @@ async def _get_messages(channel, delay=0.25): messages = [] try: - async for msg in channel.history(limit=999999999, oldest_first=False): + async for msg in channel.history(limit=None, oldest_first=oldest_first): + print(channel.name, msg.author.id, msg.content) if msg.author.id == member_id: if len(msg.attachments) > 0: attachments = "\n".join([f"Attachment: {attachment.url}" for attachment in msg.attachments]) @@ -427,13 +432,20 @@ async def _get_messages(channel, delay=0.25): msg_string = f"[{msg.created_at.strftime('%Y-%m-%d %H:%M:%S')}] {msg.content}" messages.append(msg_string) _add_message(guild, channel, msg_string) + console.print_info(f"Found message in {channel.guild.name} - {channel.name}") if len(messages) > 0: console.print_success(f"Found messages in {channel.guild.name} - {channel.name}") else: console.print_error(f"Found no messages in {channel.guild.name} - {channel.name}") - except: - console.print_error(f"Failed to fetch messages in {channel.guild.name} - {channel.name}") + except Exception as e: + if "429" in str(e).lower(): + console.print_error("Rate limited! Waiting for 5 seconds...") + await asyncio.sleep(5) + return await _get_messages(channel, delay) + else: + console.print_error(f"Error in {channel.guild.name} - {channel.name}: {e}") + return _save_data() except asyncio.CancelledError: @@ -446,14 +458,33 @@ async def _get_messages(channel, delay=0.25): finally: _save_data() - delay = 0.5 + async def _attempt_scrape(guild, delay): + tasks = [] + console.info(f"Attempting to scrape {guild.name} - {guild.id}") + + for channel in guild.channels: + if isinstance(channel, discord.TextChannel) and _get_permissions(channel): + tasks.append(_get_messages(channel, delay, oldest_first=True)) + delay += 2 + + if len(tasks) == 0: + console.print_error(f"No valid channels in {guild.name} - {guild.id}") + return + + try: + await asyncio.gather(*tasks) + except asyncio.CancelledError: + console.print_warning("Process was cancelled! Saving progress...") + stop_event.set() + _save_data() + raise + + delay = 1 autosave_task = asyncio.create_task(_autosave(5)) for guild in mutual_guilds: - for channel in guild.text_channels: - if _get_permissions(channel): - tasks.append(asyncio.create_task(_get_messages(channel, delay))) - delay += 0.5 + tasks.append(_attempt_scrape(guild, delay)) + delay += 1.5 await asyncio.gather(*tasks) stop_event.set() @@ -464,7 +495,7 @@ async def _get_messages(channel, delay=0.25): console.print_info(f"Total messages: {_count_messages()}") console.print_info(f"Total guilds: {len(data)}") console.print_info(f"Total channels: {sum(len(channels) for channels in data.values())}") - await ctx.send(file=discord.File("data/spypet.json"), delete_after=self.cfg.get("message_settings")["auto_delete_delay"]) + await ctx.send(file=discord.File(files.get_application_support() + "/data/spypet.json"), delete_after=self.cfg.get("message_settings")["auto_delete_delay"]) def setup(bot): bot.add_cog(Util(bot)) diff --git a/bot/controller.py b/bot/controller.py index 1f2feb5..deff7e1 100644 --- a/bot/controller.py +++ b/bot/controller.py @@ -15,7 +15,7 @@ from bot.helpers import cmdhelper, imgembed import utils.webhook as webhook_client from gui.helpers.images import resize_and_sharpen -from pypresence import Presence, ActivityType +from bot.tools import SpyPet if getattr(sys, 'frozen', False): os.chdir(os.path.dirname(sys.executable)) @@ -31,12 +31,42 @@ def __init__(self): self.bot_running = False self.startup_scripts = [] self.presence = self.cfg.get_rich_presence() + self.spypet = SpyPet(self) + + def start_spypet(self): + if not self.spypet.bot: + self.spypet.set_bot(self.bot) + console.success("SpyPet bot set successfully.") + else: + console.warning("SpyPet bot is already set.") + + if not self.spypet.member_id: + console.error("SpyPet member ID is not set. Please set it in the settings.") + return + + asyncio.run_coroutine_threadsafe(self.spypet.start(), self.loop) + console.success("SpyPet started successfully!") + + def stop_spypet(self): + if self.spypet.running: + asyncio.run_coroutine_threadsafe(self.spypet.stop(), self.loop) + console.success("SpyPet stopped successfully!") + else: + console.warning("SpyPet is not running.") + + def get_mutual_guilds_spypet(self): + if self.spypet.running: + return asyncio.run_coroutine_threadsafe(self.spypet.get_mutual_guilds(), self.loop).result() + else: + console.warning("SpyPet is not running. Cannot get mutual guilds.") + return [] def add_startup_script(self, script): self.startup_scripts.append(script) def set_gui(self, gui): self.gui = gui + self.spypet.set_gui(gui.tools_page.spypet_page) def check_token(self): resp = requests.get("https://discord.com/api/v9/users/@me", headers={"Authorization": self.cfg.get("token")}) @@ -213,6 +243,18 @@ def get_avatar(self, size=50, radius=5): def set_prefix(self, prefix): self.bot.command_prefix = prefix + + async def get_user_from_id_async(self, user_id): + print(f"[BotController] Getting user from ID: {user_id}") + try: + return await self.bot.fetch_user(user_id) if self.bot else None + except Exception as e: + console.print_error(f"Error getting user from ID {user_id}: {e}") + return None + + def get_user_from_id(self, user_id): + return asyncio.run_coroutine_threadsafe(self.get_user_from_id_async(user_id), self.loop).result() + get_user = lambda self: self.bot.user if self.bot else None get_friends = lambda self: self.bot.friends if self.bot else None get_guilds = lambda self: self.bot.guilds if self.bot else None diff --git a/bot/tools/__init__.py b/bot/tools/__init__.py new file mode 100644 index 0000000..b9bfd27 --- /dev/null +++ b/bot/tools/__init__.py @@ -0,0 +1 @@ +from .spypet import SpyPet \ No newline at end of file diff --git a/bot/tools/spypet.py b/bot/tools/spypet.py new file mode 100644 index 0000000..e41b9d4 --- /dev/null +++ b/bot/tools/spypet.py @@ -0,0 +1,319 @@ +import asyncio +import discord +import json +import random +from datetime import datetime +from discord.errors import HTTPException + +from utils import files +from utils.config import Config + +class SpyPetConsole: + def __init__(self, controller): + self.controller = controller + self.gui = None + + def set_gui(self, gui): + self.gui = gui + + def add_log(self, prefix, text): + self.gui.add_log(prefix, text) + + def success(self, message): + self.add_log("success", message) + + def error(self, message): + self.add_log("error", message) + + def info(self, message): + self.add_log("info", message) + + def warning(self, message): + self.add_log("warning", message) + +class SpyPet: + def __init__(self, controller): + self.bot = None + self.gui = None + self.controller = controller + self.cfg = Config() + self.running = False + self.mutual_guilds = [] + self.data = {} + self.semaphore = asyncio.Semaphore(5) + self.member_id = None + self.cache = set() + self.console = SpyPetConsole(controller) + self.tasks = [] + self.total_messages = 0 + self.user_total_messages = 0 + + def set_gui(self, gui): + self.gui = gui + self.console.set_gui(gui) + self.console.success("SpyPet GUI set successfully.") + + def set_bot(self, bot): + self.bot = bot + self.load_cache() + self.console.success("SpyPet bot set successfully.") + + def get_data_path(self): + return files.get_application_support() + f"/data/spypet/{self.member_id}.json" + + def save_data(self): + if not self.data: + return + try: + with open(self.get_data_path(), "w") as f: + json.dump(self.data, f, indent=4) + except Exception as e: + self.console.error(f"Error saving data: {e}") + + def load_cache(self): + try: + with open(self.get_data_path(), "r") as f: + self.data = json.load(f) + for guild in self.data.values(): + for channel_data in guild.values(): + for msg in channel_data.get("messages", []): + if isinstance(msg, dict) and "id" in msg: + self.cache.add(msg["id"]) + self.total_messages += 1 + self.user_total_messages += 1 + self.console.success(f"Loaded {len(self.cache)} cached messages.") + except FileNotFoundError: + self.console.info("No existing cache file found.") + except Exception as e: + self.console.error(f"Error loading cache: {e}") + + self.update_progress_labels() + + async def get_permissions(self, channel): + member = await channel.guild.fetch_member(self.member_id) + member_role = member.top_role + bot_role = channel.guild.me.top_role + + return ( + channel.permissions_for(member).read_messages and + channel.permissions_for(channel.guild.me).read_messages or + channel.overwrites_for(member_role).read_messages and + channel.overwrites_for(bot_role).read_messages + ) + + async def fetch_context_channel(self, channel): + try: + latest_msg = [msg async for msg in channel.history(limit=1)][0] + context = await self.bot.get_context(latest_msg) + if context: + self.console.success(f"Context fetched for channel: {channel.name}") + return context.channel + except Exception as e: + if "429" in str(e): + self.console.error(f"Rate limit exceeded for channel: {channel.name} - Waiting...") + await asyncio.sleep(5) + return await self.fetch_context_channel(channel) + return None + + def update_progress_labels(self): + if not self.gui: + return + + if self.total_messages > 0 or self.user_total_messages > 0: + self.gui._update_progress_labels(self.total_messages, self.user_total_messages) + + def add_message(self, guild, channel, message): + gname, cname = guild.name, channel.name + if gname not in self.data: + self.data[gname] = {} + if cname not in self.data[gname]: + self.data[gname][cname] = { + "oldest_timestamp": message.created_at.isoformat(), + "newest_timestamp": message.created_at.isoformat(), + "messages": [] + } + + entry = self.data[gname][cname] + + msg_data = { + "id": message.id, + "timestamp": message.created_at.isoformat(), + "content": message.content, + "attachments": [a.url for a in message.attachments] + } + entry["messages"].append(msg_data) + self.cache.add(message.id) + + ts = message.created_at.isoformat() + if ts < entry["oldest_timestamp"]: + entry["oldest_timestamp"] = ts + if ts > entry["newest_timestamp"]: + entry["newest_timestamp"] = ts + + self.update_progress_labels() + + async def get_messages(self, channel, delay=.5): + async with self.semaphore: + try: + if not self.running: + return + + await asyncio.sleep(delay) + + self.console.info(f"Fetching messages from channel: {channel.name}") + channel = await self.fetch_context_channel(channel) or channel + guild = channel.guild + + gname, cname = guild.name, channel.name + entry = self.data.get(gname, {}).get(cname, {}) + + oldest = entry.get("oldest_timestamp") + newest = entry.get("newest_timestamp") + + # Convert to datetime + before_dt = datetime.fromisoformat(oldest) if oldest else None + after_dt = datetime.fromisoformat(newest) if newest else None + + # Fetch newer messages + if after_dt: + async for message in channel.history(limit=None, oldest_first=False, after=after_dt): + if int(message.author.id) == int(self.member_id) and message.id not in self.cache: + self.add_message(guild, channel, message) + self.user_total_messages += 1 + self.total_messages += 1 + self.update_progress_labels() + + # Fetch older messages + if before_dt: + async for message in channel.history(limit=None, oldest_first=True, before=before_dt): + if int(message.author.id) == int(self.member_id) and message.id not in self.cache: + self.add_message(guild, channel, message) + self.user_total_messages += 1 + self.total_messages += 1 + self.update_progress_labels() + + # If no known timestamps, fetch all + if not after_dt and not before_dt: + async for message in channel.history(limit=None, oldest_first=False): + if int(message.author.id) == int(self.member_id) and message.id not in self.cache: + self.add_message(guild, channel, message) + self.user_total_messages += 1 + self.total_messages += 1 + self.update_progress_labels() + + except HTTPException as e: + if e.status == 429: + retry_after = getattr(e, "retry_after", 8) + self.console.error(f"Rate limited on {channel.name}. Retrying in {retry_after}s...") + await asyncio.sleep(retry_after) + return await self.get_messages(channel) + else: + self.console.error(f"HTTP error on {channel.name}: {e}") + except Exception as e: + self.console.error(f"Error fetching messages from {channel.guild.name} - {channel.name}: {e}") + + async def attempt_scrape(self, guild, delay=1): + if not self.running: + return + + for channel in guild.channels: + if not self.running: + return + if isinstance(channel, discord.TextChannel) and await self.get_permissions(channel): + print(f"Adding task for channel: {channel.name}") + task = asyncio.create_task(self.get_messages(channel, delay=delay)) + self.tasks.append(task) + delay += 0.85 + + async def get_mutual_guilds(self): + mutual_guilds = [] + + for guild in self.bot.guilds: + try: + member = await guild.fetch_member(int(self.member_id)) + except Exception as e: + member = None + if member: + mutual_guilds.append(guild) + self.console.info(f"Found mutual guild: {guild.name} with {len(guild.channels)} channels.") + + return mutual_guilds + + def organise_guilds(self): + self.mutual_guilds.sort(key=lambda g: len(g.members), reverse=True) + self.mutual_guilds.sort(key=lambda g: g.name.lower()) + + async def start(self): + if self.member_id is None: + self.console.error("Member ID not set.") + return + + self.running = True + self.console.info("SpyPet started.") + self.mutual_guilds = await self.get_mutual_guilds() + self.gui._check_spypet_running() + self.gui.mutual_guilds = self.mutual_guilds + + if not self.tasks: + if not self.mutual_guilds: + self.console.error("No mutual guilds found.") + await self.stop() + return + + self.organise_guilds() + self.load_cache() + + delay = 1 + for guild in self.mutual_guilds: + if not self.running: + return + await self.attempt_scrape(guild, delay=delay) + delay += 0.54 + + if not self.tasks: + self.console.error("No tasks created.") + await self.stop() + return + + for _ in range(3): + random.shuffle(self.tasks) + + self.console.info(f"Starting {len(self.tasks)} tasks...") + await asyncio.gather(*self.tasks) + + self.console.success("All tasks completed.") + await self.stop() + + async def stop(self): + self.running = False + + for task in self.tasks: + if not task.done(): + task.cancel() + self.console.warning("Cancelled a running task.") + self.tasks.clear() + + if self.data: + self.console.info("Saving data...") + self.save_data() + + self.console.success("SpyPet stopped and data saved.") + self.gui._check_spypet_running() + + def reset(self): + self.running = False + self.data = {} + self.member_id = None + self.cache.clear() + self.tasks.clear() + self.total_messages = 0 + self.user_total_messages = 0 + self.console.info("SpyPet reset. All data cleared and tasks cancelled.") + self.gui._check_spypet_running() + + def set_member_id(self, member_id): + self.member_id = member_id + self.data = {} + self.cache.clear() + self.load_cache() + self.console.success(f"Member ID set to {self.member_id}. Cache cleared and data loaded.") \ No newline at end of file diff --git a/data/gui_theme-light.json b/data/gui_theme-light.json new file mode 100644 index 0000000..ed37b3c --- /dev/null +++ b/data/gui_theme-light.json @@ -0,0 +1,27 @@ +{ + "themes": [ + { + "ghost": { + "type": "light", + "colors": { + "primary": "#433dfb", + "secondary": "#f0f0f0", + "success": "#0abf34", + "info": "#2b6eff", + "warning": "#f39c12", + "danger": "#ff341f", + "light": "#f8f9fa", + "dark": "#f0f0f0", + "bg": "#ffffff", + "fg": "#000000", + "selectbg": "#d6d6d6", + "selectfg": "#000000", + "border": "#cccccc", + "inputfg": "#000000", + "inputbg": "#ffffff", + "active": "#e6e6e6" + } + } + } + ] +} diff --git a/data/icons/play-solid.png b/data/icons/play-solid.png new file mode 100644 index 0000000000000000000000000000000000000000..b97b41c78b242e416dd049866718add392607fe3 GIT binary patch literal 39477 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 EUocy9*#H0l literal 0 HcmV?d00001 diff --git a/data/icons/rotate-left-solid.png b/data/icons/rotate-left-solid.png new file mode 100644 index 0000000000000000000000000000000000000000..7d5c1076cb5cd3e0e40100f877ac5fb37cd581c4 GIT binary patch literal 52527 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 literal 0 HcmV?d00001 diff --git a/data/icons/stop-solid.png b/data/icons/stop-solid.png new file mode 100644 index 0000000000000000000000000000000000000000..ccccacff124ca6329391f3c48b5079c841876844 GIT binary patch literal 22761 zcmeIac{r5cA3uCoD#|ic$PzOKX|ZN3S&}J)6d%$eJC%Jm*(Mbu!VJYAT9lN^lI%=H zcG4m{G4?fUWBuK8-!q@@=eeHid9LTb=O3=CEAu|*{XXyYbQTLjU7~g^pVH2$v#FuP9H6bY(7eC1BY{i#t z2YjQ=W4TUP0eJGsBMpG4@>W3mz@El!q6XU@%YLiq%d~XATd2DQpwvHTOwj+Pu($BR zR{_7-nZABx|0n01lh%Av2nRMQDkULVY>)2nHiIU101%+Hd|X%7j2#}L{vL7|cAlQE zdb;j3VcUTS%wr+E;-jeN8;goq9=r9AW_0lX9I@umy+V%A& zEfS}^dg4IplXEp{!a}=t@17L5c~T}j5UH`D@Y}5~GI$XjUGKa(19&8zAitqJyS?UN z?dtp1IC{9A<)obJ(y-Wi_1%!QXsMR|V>wrj?7R2JJfiR%%CH(&WK6^&YajIUjE2bn z5qY@A7U#zSWQ7?~&VJaLU++u_p4zfClGFT~FK{Aw?r4<2ZX$qiJSLA@0)V&*TFi;V zTY$!d^Z@`U9pS(7GM>-i3o8I%AKyBZt+kW=mAq=>}ZM_;^Yo0 zUTD`7{)bxo|DmY1vE*lO6L`#>^c(U1qnt9wa39Z=TjTD0JFNIqKk=7_v9V~?uK_F%mvqbd<37JgTb3Gkt63$s0rqU9s7to_kg9UpcWH+gT$j ze{c0(sg7Lyt=#k5er)wQQS?$o;NF+VUM=3oxGeAwpE|V&yg#6h|L`=UMSsM{8wk1= zZHsS_<8+8>x4se;e#$DsTtK3l*LKic(JV&MR@hw0cb_07)q0v0y)_cQXM*+W9mFT+ z(AZcVa|3hjm*Nv*?|F(@FRA=`4vUzmV6SQwq2GY*DcU$ap686Bz2+dx^ECOBv$fhJ}`R_{9yRi zKc-%$nm=)`cK`F*lef=|2*t8@Q^R~&@&p75>^Lfi><(6xq z%eEZ3VRuhI4%2=1k8Q!1{r8c)U*zron4fI*G}|_Q#3|9x=5j7=!oYX?ilDDdfPsm& z*DHrC2cpBHA&nf!&NLyLIGO^EZab=ZG^h2(8;3W+Z`?xItwoI&ldQ` zrdy|5M3iKYv0`HPJIg8C&NRf@jyNS=FKn5)^ZO-t{k39Z<+W!mde0;aC8ok12NeDJ zG#s+rglw{o_1Jegsae}xm+wrkOs}GrOEtzjB{>wgy#Maq&`@)vE2}N5b|PBkXNps* zb$NSf=j#2t&rhXPor%OmRzK``7+Amkq2a@&tVA;nGo0Ci*{>|U)+^&j#$8%3IG+=| zkj$Q}nq2?k<(uR;mA=U?y?guj#z=jWS}<|#kegVY*fo(fv13wY(RxvQ(Pgn&xIu_V zxb?XA@jy>+p9WuN_tNq7-^V^@R%U)4z2GS5_{8hcoFd-~{n&b*1m6Tc6PJv88LnwJ z>mLjXOcj62|Gw}nxg@#kO}opF?gsU$pw}yg(#e9g7N6CpwdQA*%=S zEVwn89K3Tib>;YS{aVc5ir=_d-aluzTW$HcWpGQ`ErDBk+nlzEu#j1v?^N7=e|yOe z8Fr0*C)wFJE{b@@xl#CL1!=YlU5-bRTK3hkKH7Ila9OrN!r(wFcXU`YYcC?$?FG$X zwBZ9Qp-;z|P$+VJKbemfv{&ko{443P^Y2Z(J-nBWv}RE+t~i!O|B1kW+|Mi(~ zLG{<_b04mq>o+$xoxOqUnvFKT{q6a4?ZcrLjy=eXudR1|JAbLjcAxbFJJabK2|q8J zg&g90V43Bf_44iC?9+uq{TI$!{tR5expZ{&oGGZA9M>IBA9u}Kx>)hspI^gf zINk{Uxj!gq>2jvW`^n843(<}58|xakDOxK|UT*l1)j9hF^K9|vqY#UFv$Z#`3(DIi zI$A5b{Q~AVZwv1`-ZLFPQJR0)|AJH5sh|bdZ&RV&F#NV1O^P88+p?7 zx$ARdFFB-OfpuILcleuXg-_PeXD*M`pOj6HL_K>Xd{tPdykH}ET5iDBr&=-QLh9p3 z2i5=3sG)HK8Fm?|kIrhU{~&kamJC+OP8R1a${e+uQ#JX#rwj@#mulyFhGQ&dO5NvC zOE#V21^RE)->T~byRZG$eRtVy+zbJxA-1pv}w^nY9YpKJO8z}a1TSgn8jQzp8*eZ+o|Km5*c4B8Tp zl8xUYbn7u|Lj=W}We96!$TExln#7tev(*E2&hcvsYZSZk&MIFM7fi_cw!1rj<@56C z#adNJ&T4fBF9rJr1PFr5ftomMU~WRc0r=-@6)oF<$TkV`RC(%D`399s55raJSkR?R zpP+4_In!ZC+9+FE3rDY%yj$|?-FB)+MSl0$x}hoCy5X8X-F%wcz=?hCu2UK$YRHsE zNR5!;)CRk`iWW<&V?y)Cf$x~=3@*B`*5RR^>Wv{U*`fmqoHGb5mM`stUh{3I=jnsj zyuFtGAk%DX7hYQTrLdv0OAUwmavB3me@cAX364{WN{UwgrZo_$+CmlEBYCiW*%(^T zYTMA<;1HdECNXG87Db|LW#RTuwg?u{#0;F?0@5FUH4GNfds}^fYlb1>RziDsP@vz^ zemY0=q^g%_QS^ze%@PF4fG_t6I?=pfoE1HT)?x{Ei=(2_w$&VS-AkwY=LBi&Bw6-z z^8A(5V$s+c8(FxG1*GGpmB^oKGEQuD)JOalG9(XFr%h?l9YQJkD_*-%u~I6c!YY(3 z`l+bRE?OkyY_6~hcp?OVtjciohOmy4hXf0Ek_#%7PNR4Q5|mG>Xx+gi6GZ8R8D0@o z^f_I`laKMu+;&Qy2s-hg*P~_+J55i7|6#2=CcZ?P&3t{1Eq#P?@vl^xj9qw~7{UDp zMsGbyCCvFW=T`cV-B4}g z!dOSB(r{?t{dy|yHcJ=}mft0g>P9X&`GRiO4~|QO=T@ z%d3tdr=9xrx*hR5Us~Snjx*>Hx~vppa)Y2)MU?Pu6FcoY2_51z#oA_WZTb*?pu;X0 zLvtE(d@$pT!TP^79J(JH$#B8ycNwzMY1c2&AEgjj|GIeUq1s-C$QjcOGIg3PV;#Pe z4W%|DKWA10h76J@IjP_lxe9)2trJG6*(Fd5(9>dJ^dTU_x^C5AuAtEJ&Q4ZTmy-Ru zNR(xqbNE@vMKh|M4b94HBIPt;tvg0&| zfD*x_?2dmNv!HE{?DG_gwl+yeRyEyfM%f z1!v#mCPPw)(X90Zf9V4b;`b%(q55EE`n`N#yUM;8o?|;>hQXfw6^}d2ED?O3$0OWj zJ568qtDnPau6Nj%|8QiQl9wClm?x=^P*K-J(-fs=AqVnHIoGOI(Ym$~+5K=EGp4 zZSTREL7BF22=-OGFFc^uhPc=hPctZ-Yy%(afIsG$Tzch3U}~IJ5=7M}q2OwnDRdcf zto`05SIma0Z$Hfr8Qq8b@W%jmKDy!37tD7yD>J&fJak~_5*w@-^|{{aRa zfS)eJCbVXbDI}W{6vD@iP5oDh52&nKvgqu4FnbslWi{_@&Q3dpz;*5Q;h@`y_v!p1 zrDMc{Eq7)5_*iLTf(vNEQjHL!}OsezhNR>v( zJr=d7L0P*XxnJFqY2?4{s>7n552&6q5TF9|Ehg=L+Hrb@s#t2r=X}&pnILF#wM-A6vx%d%2i$!qmM3j<9M<6B%(Pzce)Y=BiY!E_+UmV; zCLu*A_a^8@(d-mKqcj-lf5g6S^~ieqqnn7Ly1*;YZUkhfL^d>2jasEd$oYD+mz~35 zb~$iwma7Ffgsa2xN?4%>5H2|?0*Rzg#doXI<^Ckd~Rq_`==oE1o($hl8dG5@zD)Q%t_{vL1#xsvP?P< zGzO>v=8<41R^r?b38G5S710NveFpf;2j{HGS#iSZL%ZWQ;R^4w4Ah;iZqZg7+#B6c z%%~&4DZg>`NXXCDa-@9KDH!LneOIzpLl^Sh2&?=sq5!R~;C>#G-&%^yjg0&O1*R`P zYcX^|aVIxlCZsauIxY6W)$H)r6r0z7@nFHT5dmgZ_azHb+?`b9$8Cpp|Af|{AD?B+ zMQ(5iq7oQz_4ucR*KU3~X`3&7Hq zlY)kh64wYJfz+@XQ%0RA^O+1`WGi2C5TzK>`no0*uIbzT_|F_AlN~M*QEdn^GijTC zYHa&}J?%`m!hkEhm!iGvb{$0<&Qa` zHNDaE(=aJx@F2%;{)AU<@|G{44HB4%byKj+El;#{CbTgEb|BAR-~1-*mZP*7m{Zav z4X1Bc@k29v{(m<2d6*2n$WDB&gw=2;*M>{APfm$kfaT2r-FJ%NzCIj=-1!OhkR26b zG2;eTMG&vkZhc{Lel4>^Df)rQw7*%mFWu0t^o7%WMj-N7*mDeaok%AAUbyQ#}N?O~#HeZcpei*DVB zBL9IExXe-q`j5H=J0Uz6kmt_sc7w-Ftb7Qu7J-JBFKVeI-$)!K4oO=I0{CAzc z#H-yBR|Du6C-QDh2NryFG#R~;P(GRuYWOZt1abo9@|Dsd!*?90keh=m5C!PzH@)AV zuj5cQ20pN$|LmNZdhX^A9@(o7_rNiCKgcH&^w}x2#_yd!4E~fSrG=)YLp513dKj6Y zJrEz|Ru`JKDDLJz8df90z;xMG37Ls~G8`yAnN>Ku@#0rXsR@<>*e(&+maLDFNmcsO z2$~LQRNM`c1ejAaYL%MT1{0hK6O02IFfMEF%GW&mO%!I!<}Dds@7E6{tiUjVSH$_2 z_}2=fWQv^`3C-H0KAezRg3JwA6N2SNwu2NFZsNJVd;mAnk2!wR}J38DV`sU);@XtM~Q%3`!)pZ|FhkT znU?;C2+mU)FnjBbILVt|OtkC*9&5prhwpB>B69Lq z@QgXa;|Y8oaX(Yufk`c9mi<4@<%_rd3JMA=SK2Rx!0g$^(+6(;aRRqA_~{CGYHpZ% z!dJnI{SXQM{gD2XoieE{6MU4gYF(rPGw~{@&p+jWo%^`fkE`r&zG&`6R~WAFRnDM9 zx;fhKH2=biQP}*K26uD05GEd`!HH%iS0ZESe~@4h8N*yve#HN1#<(;`F& zF%M=6UezB5S)BhTBU#20frX~yG2rk1;DfJ~L8pybIB_TuIKPc#86_;HI+?j@@=7n$ zSzA_W(yOJI0n!OaL`UX0*CyxU%*4kS-px}f();hfg=u# z2mw1k6m2w?bar;G^lnTsp2F=}ochie-2iJf*3E_LJg?1I!fItSaz)Edbs7Fz4Ahe~ zf_~S)nDV%1O*D7EBAla?tuKr$nX12@%#aZx!)Ku%dl(+LxpccmB+L+Yr@Q}#v&a-9 zKQFobM`$Xetp3NxNXv86^q^(Da}!xP1!;%hGcTQkgtboDq6O>mQj)RBFEFa>@&B_x zBgXn;Yz$ASZwlG;$^Sj^Hhr?`6JQfhPHe*CxlJh8gn~^d05+ij*n|RL6ACt=U=s>} zO(+01p#a!~f=wvcgaYuv+~%oZ6ACt=U=s>9p@9BZip|G{o6ib1zaVU$3O1nt^uQ(* zY(l{%6#RdNg1;4uQ;Ws-#SA~sJi8A>TV=ARvp&3M^_=x|l$8$mgf)wkj+Jug6ERt# zTgSKMi@7^e*3N%IJf+==as9{dcX3k-5zEu(bYD$#dGRvTjB$+l=c^=Umqa78J7b91 z12M5zbP}h zgIL}Qx0?ccMhYt0z~0N$5JvA>?P^Jovixokn!p>7|JJ#i62Ut^3?c%FCkoc8ToL=5 zG#DL+-eHsfHhCRu4rBBVzOr74R9aeEDoHEc=uCi{vyOaE{?F8D55%Yw-0cm><_0e* zF>&T8_&c);Eon)auzJ;g7qm9sBj}f!Go!DoF+d1gEWqebi^}R;Ya9@jPjP`SP2fk_ zODb?e*yTJ%bD-=aP^jC~^t4FG$6iMFW45fDZ^+z_=VNQQA%<<(jlbKNY9y`y*~!E} z#kB!0wSCz26lRZK?dlsJkWf52%-}Rw`n%0&JJEU;zS9G|P*7W^B(JQjWR%a;axv_u zBv*_0-_S;TJSB6}HqaS!K>f@X?DzZqhfjxEm6_49dr_fD4V*!e|Y zD_!v4;Cugfw)#>8J%BQ0Gw;x^iQzC0e`hQ;d{~3_Elk%&AMUg>51V>!RL+aNk_G3` zk&lsvpP6Y83Xbh!bk*%HPSBuTLX1|dG1Psn@oqlqSLnZz5(}pCW!fYOh30T)bzx3U*&y`Yiny7Zts8b!P@*YGXyd?YNPcq8I261A{sR;-@73^V&FuS)d^5! zXLLYPNG^-Cr$N&Qy)H30A`Q*2m%w|(ZUpoPMig-E6DxPaS-6)NY37ydSHkr9k)IB| zGHO&#BARx%c8p>ToACvMrdjvOXxM3^a~KWa4WEZ%Wi*Zv3Uy!?CEmWBRP{v1!9tvN zvj#3MKw<`?S24{eQjGj2GRw%nJtJOL@05=b(6!y%?-&lR?|veqEb*_Xm!o}V9wWl2 z%Q|JSfmSSToIxMu1W^&Td5}VQ)v0J)OO6)xmzX8oxa)rJnLt&Jj>8+$s!s!adk#1| zqfPq#l~1??DUTkF4um_>DRRX^+}fmp+q$Gx&reRU2An?L$;yj0S?HoOvkPr}aEGeU zhzn^wkuNnD`K>zVBD5|A=dB_ihR)bdto)af@&kQVMgFjdGkkf*{eX^0!h@9Z|1iBT zPC;mszTejEZ)0?*Uy>{!n8Y-k5lV&|M-9f)2XaXs80<1SnY#zJs4vIxY!tFqSqse+ zEqnjGKqbG9gLv(sT1(qfxapS?Eka_?D6cgaL}`;2bf;l~vGI$tQGZU`ZU^J&Q^{yx z=YOLf`{xZd=AaEH=Ju{P17%INLN7i!L zR*dcyMCs+Gjs!to$A5T};r~4ogMHiY3Ez?FbANXAMY-b7FkO*^*RU$#?RKI3Bm+KB zy$rTqxQX{#vz?3eE3v!}Id8H`uRyIl3*TBlFQ-#{Olu+*I9>mp_1EpV439wwdCcGMWOUm`@)D! zLvMS>M-^$)-UY=$62t7=1*wG0v?>)PERSCVq&iS_G5KQ=`1vvnEbgPrDIUksDjWa9 zB*!sF@*}tLJ$E2X7u$LVi-s>yFlE!HO8PS$LNp4ljFl z5i@vl;bP+VFkRzyD~CMj)c^n>G>B2Shpb)2U`cila>a9BF|H1}-$B|Rw|jZ~%hJ#X zBq?6=u?$mC^sKl-B&e?v_QSgF?At}g{gs|#^t2P#pLi8lf^$0@X}mpoGH*>~C()4P zGYGxt0Kl(hnWRoOe@g+&{7N>wZRpF|h4E!hC(Y3{>vMJDg43jQ1fviQY6h;uoH_Ty z{sn#JN?1*+vy3(D_}v8r$G`e)sAROJSt`u>wiFu-t>>a@buEYCN#RP z<5CA*O}raZO};VAZqOa_pqjz@o)!$Tg`&x5ksDkdQQ|v^uhAiuEih8Lb83^8^p=Zl zg&ZS5*$W=tFg&KzyYLEVlkD9H&TEHjt2^Ms&56%bq`+6|l~R{jQLoXK0}MqHKgCjA zc>RgXZ^gl@h>GZ0)N7(RjRp<@7-Mx8O-$7bv;e} z;FHp|lWEi-fi_OP4)EG)-G*pn`BxY<9H=G&5lFquT4=pubO9sm_`4;BxS^h?htUlk z3ztdb3>7I`T}Xj1)hpXBanMz)Sc7FxIO@(~ID{YM|Lh=MysX_N32(aLH^zEmE#B-P zrf1C;8o_9}=#@O{T&DNt06D)t2rlcHTtk6UCA(kZEJRZ#YxDkZBW1{vOB(V{PJ|VB z%kEQY>=Dcz=Lyv{HVZ2M!MN|}Fr+U~PUjXP=Z_Dg)0lx$$Uf_75SYATtomud#g->n?V3!b*y2+O}o~&Tzbn) zCt{1^9e?u~BJ{%IciV*(+d?8JBVqBjx!|WC!!=nuf9VCkW`sFvB&ZI&5tWCWwTjza zZUB`%ey9t}PE*pN28{nNWP7?_sLc%t>Nu!R{>KkG=G76pA-3Ao>q?BP&gQ8;m9diT z2wcyhLDGVsPc^hYzRX)s1HFGNTFwZAO;t)?tr{JJ-zx#U8$*Sa>LPIMi1Sbo@wjo2 za0Y?vj`i&6ILAn^zsX4OoouHN?M8fzd)v7E{qfJr*ib9usC&>FvRvAgvA0=K;iC+~ zxnBr5s#R#s?_vU+3qRzr>?-#}JyWEg!Ua352ojP+Jxl*3&}T%5dL}Um-vv-%DFz4? z3qFQ+*V(zXPa_FY&ms>(c}OV?BOsq~W50YqwHE>mDINnNdQ zQsc*5#llj+_dcj5Qy#m(Z>YLV~$19VeDI)(%E( zdK``#W&b?4_N!c^aqs^{*$8hbIXceU=Yb=!)SwhT2m-%8^{G?n1FM1+H3!~e*Jo^K z{EZ3mjuj1fvAm&SRq4ynRP$V9ZAN@aI+;xnHJ29=3FGa}hu3(??uvOfMeGFj7IietmpL26)U3lLlX8 zG0yPsG5bfe6ZznXZ8q{-YK&fR^zL6!4n>1N4DP~y>|gA()7AZ?5|+Cd`Cvty>fi4a z$3nz#NI7-{MeGiSbXk;3S2J1L+iaRlt;t%ncrNtB#{#Y#e0xW_5b@_P;oDN}i0hVV zRBcAg)iCL5$JY>Y={O8ly8e*;WvVR$#9o!?x9k!5FNdBhDNy$!5?a|jNqTVqFnBT0 zmldUEAD4S_XahR5;Ggpwr(Rb*5Gl!qDz$q=bs=@uc|jg2rD6sz4i(P|H6()Xd--Kr zw}uj7B=3xypVZQwd7o-4HfKWiI}I@5@|G~Bl921AyCnuM4rUOL6SZNi)ys*wa?DMx zFLYi?5DA)T72!;9&nk&mpt(M!K!N{0W|DfU{67CDFhOJ=HcJVLF@w*s-xzbU6?DM% zP*&SZ;xtFnMuaTHBjlz#YeEvKSQ9uaL6E&`dIvhe^|@=`K}etovd>NH2FEu96W&4g zH;>14z3AeS{~>h8mkpItKg=oy`FCkK9+3;yLc$5e?=B5V@UAH{xAwV^R(X(j6nL;N z2UQ27i7^Z$Zn_qi%bg3BQUdq_rVp>wC<>soM9778&HOd zCNU2=7&@QUZ+^t#48mGUBSR>InU+@75`*ShKHYMCKH$5IydLB#&dN;*&{x6>EXNOI zJKxSe2o5aD)rMp^$I#USzF(50DNgOMe!{|iDf>)>So9CNAMqY8#?h6<`bCl~y!wbI ziWhJMvFRJ|>!Ei497KlBEAG4{$A)@8ai|}{hlyvAtS1LpxTUL1D*Jlx3}4`h3sQdumfSQySAp;A3b=q{ zMgNlOMALQ5-m6VAJg`;Q(rs@dpBBqi-?hnVOe}qb(&cQtwPvW3tB);|RcJ_cD;u5a zi?%W@Ezisrg38Dn9jL`TmPrpb~Ttr^Z<&OTB^ u+m)i=Vh_9k^Z6HU<9q;pIWTIOYnr;!KZkP", lambda e: self.go_back()) + back_button.grid(row=0, column=1, sticky=ttk.W, padx=(0, 10)) + + page_name = ttk.Label(wrapper, text=self.title, font=("Host Grotesk", 16, "bold")) + page_name.grid(row=0, column=2, sticky=ttk.W) + + return wrapper + + def draw(self, parent): + # Draw shared navigation + navigation = self.draw_navigation(parent) + navigation.pack(side=ttk.TOP, fill=ttk.X, pady=(0, 10)) + + # Create shared content wrapper + if self.frame: + wrapper = RoundedFrame(parent, radius=10, style="dark.TFrame") + else: + wrapper = ttk.Frame(parent) + wrapper.pack(side=ttk.TOP, fill=ttk.BOTH, expand=True, pady=(10, 0)) + + # Call subclass-specific content render + self.draw_content(wrapper) + + @abc.abstractmethod + def draw_content(self, wrapper): + """Implemented by subclasses to draw page-specific content inside the wrapper.""" + pass diff --git a/gui/helpers/images.py b/gui/helpers/images.py index a2b00b5..8ec77e0 100644 --- a/gui/helpers/images.py +++ b/gui/helpers/images.py @@ -2,10 +2,20 @@ import os import threading from utils.files import resource_path +import requests +from io import BytesIO +from collections import Counter def resize_and_sharpen(image, size): - resized_image = image.resize(size, Image.LANCZOS) - sharpened_image = ImageEnhance.Sharpness(resized_image).enhance(2.0) + try: + resized_image = image.resize(size, Image.LANCZOS) + except Exception as e: + print("error resizing image:", e) + try: + sharpened_image = ImageEnhance.Sharpness(resized_image).enhance(2.0) + except Exception as e: + print("error sharpening image:", e) + sharpened_image = resized_image return sharpened_image class Images: @@ -54,7 +64,7 @@ def _load_images(self): "bigger": ["scripts"], "small": ["trash", "github", "restart", "checkmark", "left-chevron", "file-signature", "trash-white"], "tiny": ["submit", "max", "min", "search"], - "smaller": ["folder-open", "plus"], + "smaller": ["folder-open", "plus", "reset", "play", "stop"], "logo": ["ghost-logo"], } @@ -84,6 +94,9 @@ def _load_images(self): "file-signature": "data/icons/file-signature-solid.png", "left-chevron": "data/icons/chevron-left-solid.png", "tools": "data/icons/screwdriver-wrench-solid.png", + "reset": "data/icons/rotate-left-solid.png", + "play": "data/icons/play-solid.png", + "stop": "data/icons/stop-solid.png", } for key, path in ICON_PATHS.items(): @@ -131,3 +144,35 @@ def get(self, key, hover_colour=None): return ImageTk.PhotoImage(image) return self.images.get(key) + + def get_majority_color_from_url(self, image_url: str) -> str: + response = requests.get(image_url) + image = Image.open(BytesIO(response.content)).convert("RGB") + + # Crop center to focus on subject + w, h = image.size + crop_margin = 0.2 + image = image.crop(( + int(w * crop_margin), + int(h * crop_margin), + int(w * (1 - crop_margin)), + int(h * (1 - crop_margin)) + )) + + # Slight blur to reduce texture noise + image = image.filter(ImageFilter.GaussianBlur(radius=1)) + image = image.resize((50, 50)) # reduce size for performance + + pixels = list(image.getdata()) + + # Filter out very dark pixels (likely shadows) + filtered_pixels = [rgb for rgb in pixels if sum(rgb) > 60] # brightness threshold + + if not filtered_pixels: + # fallback to original pixels if all were filtered + filtered_pixels = pixels + + counter = Counter(filtered_pixels) + most_common = counter.most_common(1)[0][0] + + return '#{:02x}{:02x}{:02x}'.format(*most_common) \ No newline at end of file diff --git a/gui/main.py b/gui/main.py index 8a2603d..cef54f0 100644 --- a/gui/main.py +++ b/gui/main.py @@ -12,7 +12,7 @@ from utils.files import resource_path from utils import uninstall_fonts -from gui.pages import HomePage, LoadingPage, SettingsPage, OnboardingPage, ScriptsPage +from gui.pages import HomePage, LoadingPage, SettingsPage, OnboardingPage, ScriptsPage, ToolsPage from gui.components import Sidebar, Console from gui.helpers import Images, Layout @@ -20,9 +20,6 @@ class GhostGUI: def __init__(self, bot_controller): self.size = (600, 530) self.bot_controller = bot_controller - - if bot_controller: - self.bot_controller.set_gui(self) enable_high_dpi_awareness() @@ -67,8 +64,12 @@ def __init__(self, bot_controller): self.home_page = HomePage(self.root, self.bot_controller, self._restart_bot) self.settings_page = SettingsPage(self.root, self.bot_controller) self.scripts_page = ScriptsPage(self, self.bot_controller, self.images) + self.tools_page = ToolsPage(self.root, self.bot_controller, self.images, self.layout) logging.set_gui(self) + + if bot_controller: + self.bot_controller.set_gui(self) def _show_window(self): self.root.deiconify() @@ -101,6 +102,7 @@ def draw_tools(self): self.sidebar.set_current_page("tools") self.layout.clear() main = self.layout.main() + self.tools_page.draw(main) # def draw_loading(self): # self.layout.hide_titlebar() @@ -142,8 +144,8 @@ def _on_bot_ready(self): self.layout.resize(600, 530) self.layout.center_window(600, 530) - self.draw_home() - self.notifier.send("Ghost", "Ghost has successfully started!") + self.root.after(50, lambda: self.notifier.send("Ghost", "Ghost has successfully started!")) + self.root.after(50, lambda: self.draw_home()) def _check_bot_started(self): if self.bot_controller.bot_running: diff --git a/gui/pages/__init__.py b/gui/pages/__init__.py index 904232f..a6cfea0 100644 --- a/gui/pages/__init__.py +++ b/gui/pages/__init__.py @@ -3,4 +3,5 @@ from .settings import SettingsPage from .onboarding import OnboardingPage from .scripts import ScriptsPage -from .script import ScriptPage \ No newline at end of file +from .script import ScriptPage +from .tools import ToolsPage \ No newline at end of file diff --git a/gui/pages/tools/__init__.py b/gui/pages/tools/__init__.py new file mode 100644 index 0000000..5496ec9 --- /dev/null +++ b/gui/pages/tools/__init__.py @@ -0,0 +1,4 @@ +from .spypet_page import SpyPetPage +from .tools import ToolsPage +from .message_logger_page import MessageLoggerPage +from .user_lookup_page import UserLookupPage \ No newline at end of file diff --git a/gui/pages/tools/message_logger_page.py b/gui/pages/tools/message_logger_page.py new file mode 100644 index 0000000..e1fd577 --- /dev/null +++ b/gui/pages/tools/message_logger_page.py @@ -0,0 +1,12 @@ +import ttkbootstrap as ttk +from gui.components import RoundedFrame, ToolPage + +class MessageLoggerPage(ToolPage): + def __init__(self, toolspage, root, bot_controller, images, layout): + super().__init__(toolspage, root, bot_controller, images, layout, title="Message Logger") + + def draw_content(self, wrapper): + title = ttk.Label(wrapper, text=self.title, font=("Host Grotesk", 16, "bold")) + title.pack(side=ttk.TOP, anchor=ttk.W, padx=(20, 0), pady=(20, 0)) + + # Any additional widgets go here \ No newline at end of file diff --git a/gui/pages/tools/spypet_page.py b/gui/pages/tools/spypet_page.py new file mode 100644 index 0000000..49af174 --- /dev/null +++ b/gui/pages/tools/spypet_page.py @@ -0,0 +1,584 @@ +import sys, time +import ttkbootstrap as ttk +from ttkbootstrap.scrolled import ScrolledFrame +from ttkbootstrap.tableview import Tableview +from gui.components import ToolPage, RoundedFrame, RoundedButton +from utils.console import get_formatted_time + +class SpyPetPage(ToolPage): + def __init__(self, toolspage, root, bot_controller, images, layout): + super().__init__(toolspage, root, bot_controller, images, layout, title="ghetto spy.pet", frame=None) + self.wrapper = None + self.search_entry = None # Initialize search entry to None + self.user_id = None + self.user = None + self.user_avatar = None + self.user_wrapper = None + self.mutual_guilds = [] + self.spypet = self.bot_controller.spypet + self.log_wrapper = None + self.search_placeholder_text = "Search a Discord user ID..." + self.console = [] + self.textarea = None + self.avatar = None + self.darwin_font_size = 12 + self.non_darwin_font_size = 10 + self.reset_button_disabled = False + self.messages_textarea = None + self.messages_textarea_updating = False + self.messages_formatted = [] + self.current_search = "" + self.messages_all = [] + self.messages_displayed = [] + self.total_messages = 0 + self.user_total_messages = 0 + + def _redraw_user_wrapper(self): + if self.user_wrapper: + self.user_wrapper.destroy() + self.user_wrapper = self._draw_user_wrapper(self.user_progress_wrapper) + self.user_wrapper.pack(side=ttk.BOTTOM, fill=ttk.BOTH, expand=True) + self.user_wrapper.set_width(200) + + def _check_mutual_guilds(self): + if not self.mutual_guilds: + self.root.after(100, lambda: self._check_mutual_guilds()) + else: + self.add_log("info", f"Found {len(self.mutual_guilds)} mutual guilds for user ID: {self.user_id}") + self._redraw_user_wrapper() + + def _get_user(self, user_id): + if user_id is None or not user_id.isdigit(): + self.add_log("error", "Invalid user ID. Please enter a valid Discord user ID.") + return + + if user_id == self.user_id or self.spypet.member_id == user_id: + self.add_log("info", "You are already viewing this user.") + return + else: + self.add_log("warning", f"Switching to user ID: {user_id}") + self._reset_spypet() + + self.user_id = user_id + self.spypet.set_member_id(user_id) + user = self.bot_controller.get_user_from_id(int(user_id)) + self.user = user + + avatar_url = self.user.avatar.url if self.user and self.user.avatar else "https://ia600305.us.archive.org/31/items/discordprofilepictures/discordblue.png" + print(avatar_url) + self.user_avatar = self.bot_controller.get_avatar_from_url(avatar_url, size=65, radius=65//2) + self.user_banner_colour = self.images.get_majority_color_from_url(avatar_url) + + self._check_mutual_guilds() + self._redraw_user_wrapper() + + def _search(self): + pass + + def _configure_start_stop_button(self, running): + try: + if running: + self.start_stop_button.configure(image=self.images.get("stop"), bootstyle="danger") + else: + self.start_stop_button.configure(image=self.images.get("play"), bootstyle="primary") + except Exception as e: + print(f"Error configuring start/stop button: {e}") + + def _check_spypet_running(self): + if self.spypet.running: + self.search_placeholder_text = "Search for a message..." + self.messages_textarea_updating = True + self.root.after(50, lambda: self._disable_reset_button()) + self.root.after(50, lambda: self._configure_start_stop_button(True)) + + if not self.search_button.winfo_ismapped(): + self.search_button.grid(row=0, column=2, sticky=ttk.E, padx=(0, 10), pady=10) + else: + self.search_placeholder_text = "Search a Discord user ID..." + self.messages_textarea_updating = False + self.root.after(50, lambda: self._enable_reset_button()) + self.root.after(50, lambda: self._configure_start_stop_button(False)) + + if self.search_button.winfo_ismapped(): + self.search_button.grid_forget() + + if not self.spypet.running and len(self.messages_all) > 0: + self.search_placeholder_text = "Search for a message..." + if not self.search_button.winfo_ismapped(): + self.search_button.grid(row=0, column=2, sticky=ttk.E, padx=(0, 10), pady=10) + # elif self.spypet.running and len(self.messages_all) > 0: + # self.search_placeholder_text = "Search for a message..." + + try: + self.search_entry.configure(foreground="grey") + self.search_var.set("") + self.search_entry.delete(0, ttk.END) + self.search_entry.insert(0, self.search_placeholder_text) + except Exception as e: + print(f"Error resetting search entry: {e}") + + def _disable_reset_button(self): + self.reset_button_disabled = True + try: + if hasattr(self, 'reset_button_wrapper') and self.reset_button_wrapper: + self.reset_button_wrapper.set_background(background=self.root.style.colors.get("dark")) + if hasattr(self, 'reset_button') and self.reset_button: + self.reset_button.configure(background=self.root.style.colors.get("dark")) + except Exception as e: + print(f"Error disabling reset button: {e}") + + def _enable_reset_button(self): + self.reset_button_disabled = False + try: + if hasattr(self, 'reset_button_wrapper') and self.reset_button_wrapper: + self.reset_button_wrapper.set_background(background=self.root.style.colors.get("danger")) + if hasattr(self, 'reset_button') and self.reset_button: + self.reset_button.configure(background=self.root.style.colors.get("danger")) + except Exception as e: + print(f"Error enabling reset button: {e}") + + def _toggle_spypet(self): + search_text = self.search_entry.get().strip() + + if not self.spypet.running: + if self.user and int(self.user.id) == int(self.spypet.member_id): + self.bot_controller.start_spypet() + elif not search_text or search_text == self.search_placeholder_text: + self.add_log("error", "Please enter a valid Discord user ID to start SpyPet.") + return + else: + self._get_user(search_text) + if not self.user: + self.add_log("error", "Failed to fetch user. Please check the user ID.") + return + + self.bot_controller.start_spypet() + self.add_log("info", f"SpyPet started for user ID: {self.user_id}") + + self.messages_textarea_updating = True + self.root.after(50, lambda: self.update_messages()) + else: + self.bot_controller.stop_spypet() + self.add_log("info", "SpyPet stopped.") + + self._check_spypet_running() + + def _reset_spypet(self): + if self.spypet.running: + self.add_log("warning", "You cannot reset SpyPet while it is running. Please stop it first.") + return + print("Resetting SpyPet...") + self.clear() + self.spypet.reset() + self.user_id = None + self.user = None + self.user_avatar = None + self.mutual_guilds = [] + self._redraw_user_wrapper() + self.clear_messages() + self._check_spypet_running() + self._update_progress_labels(0, 0) + + def _draw_start_stop_button(self, parent): + def _hover_enter(_): + wrapper.set_background(background="#322bef" if not self.spypet.running else "#de2d1b") + self.start_stop_button.configure(background="#322bef" if not self.spypet.running else "#de2d1b") + + def _hover_leave(_): + wrapper.set_background(background=self.root.style.colors.get("primary") if not self.spypet.running else self.root.style.colors.get("danger")) + self.start_stop_button.configure(background=self.root.style.colors.get("primary") if not self.spypet.running else self.root.style.colors.get("danger")) + + wrapper = RoundedFrame(parent, radius=(10, 10, 10, 10), bootstyle="primary" if not self.spypet.running else "danger") + wrapper.bind("", _hover_enter) + wrapper.bind("", _hover_leave) + wrapper.bind("", lambda e: self._toggle_spypet()) + + self.start_stop_button = ttk.Label(wrapper, image=self.images.get("play"), style="primary") + self.start_stop_button.configure(background=self.root.style.colors.get("primary")) + self.start_stop_button.pack(side=ttk.LEFT, padx=15, pady=14) + self.start_stop_button.bind("", lambda e: self._toggle_spypet()) + self.start_stop_button.bind("", _hover_enter) + self.start_stop_button.bind("", _hover_leave) + + return wrapper + + def _draw_reset_button(self, parent): + def _reset(_): + if not self.reset_button_disabled: + self._reset_spypet() + + def _hover_enter(_): + self.reset_button_wrapper.set_background(background="#de2d1b" if not self.reset_button_disabled else self.root.style.colors.get("dark")) + self.reset_button.configure(background="#de2d1b" if not self.reset_button_disabled else self.root.style.colors.get("dark")) + + def _hover_leave(_): + self.reset_button_wrapper.set_background(background=self.root.style.colors.get("danger") if not self.reset_button_disabled else self.root.style.colors.get("dark")) + self.reset_button.configure(background=self.root.style.colors.get("danger") if not self.reset_button_disabled else self.root.style.colors.get("dark")) + + self.reset_button_wrapper = RoundedFrame(parent, radius=(10, 10, 10, 10), bootstyle="danger" if not self.reset_button_disabled else "dark") + self.reset_button_wrapper.bind("", _reset) + self.reset_button_wrapper.bind("", _hover_enter) + self.reset_button_wrapper.bind("", _hover_leave) + + self.reset_button = ttk.Label(self.reset_button_wrapper, image=self.images.get("reset"), style="danger" if not self.reset_button_disabled else "dark") + self.reset_button.configure(background=self.root.style.colors.get("danger") if not self.reset_button_disabled else self.root.style.colors.get("dark")) + self.reset_button.pack(side=ttk.LEFT, padx=15, pady=14) + self.reset_button.bind("", _reset) + self.reset_button.bind("", _hover_enter) + self.reset_button.bind("", _hover_leave) + + return self.reset_button_wrapper + + def _draw_search_bar(self, parent): + entry_wrapper = RoundedFrame(parent, radius=(15, 15, 15, 15), bootstyle="dark.TFrame") + + def on_focus_in(event): + if self.search_entry.get() == self.search_placeholder_text: + self.search_entry.delete(0, ttk.END) + self.search_entry.configure(foreground="white") + + def on_focus_out(event): + if self.search_entry.get() == "": + self.search_entry.insert(0, self.search_placeholder_text) + self.search_entry.configure(foreground="grey") + + self.search_var = ttk.StringVar() + self.search_var.trace_add("write", lambda *args: self._on_search_change()) + + self.search_entry = ttk.Entry( + entry_wrapper, + bootstyle="dark.TFrame", + textvariable=self.search_var, + font=("Host Grotesk",) + ) + self.search_entry.grid(row=0, column=0, sticky=ttk.EW, padx=(18, 0), pady=10, columnspan=2, ipady=10) + + self.search_entry.insert(0, self.search_placeholder_text) + self.search_entry.configure(foreground="grey") + self.search_entry.bind("", on_focus_in) + self.search_entry.bind("", on_focus_out) + + self.search_button = ttk.Label(entry_wrapper, image=self.images.get("search"), style="dark.TButton") + self.search_button.bind("", lambda e: self._on_search_change()) + + entry_wrapper.columnconfigure(1, weight=1) + return entry_wrapper + + def _draw_header(self, parent): + header = ttk.Frame(parent) + + search_bar = self._draw_search_bar(header) + search_bar.grid(row=0, column=0, sticky=ttk.EW, padx=(0, 5)) + + start_stop_button = self._draw_start_stop_button(header) + start_stop_button.grid(row=0, column=1, sticky=ttk.E) + + reset_button = self._draw_reset_button(header) + reset_button.grid(row=0, column=2, sticky=ttk.E, padx=(5, 0)) + + header.grid_columnconfigure(0, weight=1) + + return header + + def update(self): + try: + self.textarea.delete("1.0", "end") + + for time, prefix, text in self.console: + # self.textarea.insert("end", f"[{time}] ", "timestamp") + self.textarea.insert("end", f"[{prefix}] ", f"prefix_{prefix.lower()}") + self.textarea.insert("end", f"{text}\n", "log_text") + + self.textarea.yview_moveto(1) + except: + print("SpyPet console tried to update without being drawn.") + + def clear(self): + self.console = [] + try: + self.textarea.delete("1.0", "end") + except: + print("SpyPet console tried to clear without being drawn.") + + def add_log(self, prefix, text): + time = get_formatted_time() + self.console.append((time, str(prefix).upper(), text)) + self.update() + + def _load_tags(self): + self.textarea.tag_config("timestamp", foreground="gray") + self.textarea.tag_config("log_text", foreground="lightgrey") + + self.textarea.tag_config("prefix_sniper", foreground="red", font=("JetBrainsMono NF Bold", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size)) + self.textarea.tag_config("sniper_key", foreground="#eceb18", font=("JetBrainsMono NF Bold", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size)) + self.textarea.tag_config("prefix_command", foreground="#0b91ff", font=("JetBrainsMono NF Bold", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size)) + self.textarea.tag_config("prefix_info", foreground="#2aefef", font=("JetBrainsMono NF Bold", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size)) + self.textarea.tag_config("prefix_success", foreground="#4fee4c", font=("JetBrainsMono NF Bold", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size)) + self.textarea.tag_config("prefix_warning", foreground="#eceb18", font=("JetBrainsMono NF Bold", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size)) + self.textarea.tag_config("prefix_error", foreground="red", font=("JetBrainsMono NF Bold", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size)) + self.textarea.tag_config("prefix_cli", foreground="pink", font=("JetBrainsMono NF Bold", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size)) + self.textarea.tag_config("prefix_rpc", foreground="pink", font=("JetBrainsMono NF Bold", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size)) + + def _draw_log_wrapper(self, parent): + wrapper = RoundedFrame(parent, radius=(15, 15, 15, 15), bootstyle="dark.TFrame", custom_size=True) + + self.textarea = ttk.Text(wrapper, wrap="word", height=20, + font=("JetBrainsMono NF Bold", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size) + ) + self.textarea.config( + border=0, + background=self.root.style.colors.get("dark"), + foreground="lightgrey", + highlightcolor=self.root.style.colors.get("dark"), + highlightbackground=self.root.style.colors.get("dark"), + state="normal" + ) + + # Disable insert/delete actions + self.textarea.bind("", lambda e: "break") + self.textarea.bind("", lambda e: "break") # Middle-click paste (Linux) + self.textarea.bind("", lambda e: "break") + self.textarea.bind("", lambda e: "break") + + self.textarea.pack(fill="both", expand=True, padx=5, pady=5) + self._load_tags() + + return wrapper + + def _draw_user_wrapper(self, parent): + wrapper = RoundedFrame(parent, radius=(15, 15, 15, 15), bootstyle="dark.TFrame", custom_size=True) + wrapper.set_width(200) + + if self.user_avatar: + accent_colour_banner = RoundedFrame(wrapper, radius=(15, 15, 0, 0), background=self.user_banner_colour, parent_background=self.root.style.colors.get("bg")) + accent_colour_banner.set_height(85) + accent_colour_banner.pack(side=ttk.TOP, fill=ttk.X) + accent_colour_banner.columnconfigure(0, weight=1) + + avatar_label = ttk.Canvas(wrapper, width=100, height=200, background=self.user_banner_colour, highlightthickness=0) + + avatar_label.create_rectangle(0, 0, 100, 50, fill=self.user_banner_colour, outline="") + avatar_label.create_rectangle(0, 50, 100, 200, fill=self.root.style.colors.get("dark"), outline="") + + # create an ovel the same size of the avatar but an extra 5px on each side and use the dark background color, this is to create a border + avatar_label.create_oval(9, 8, 85, 85, fill=self.root.style.colors.get("dark"), outline="") + avatar_label.create_image(65//2 + 15, 65//2 + 15, image=self.user_avatar, anchor="center") + + avatar_label.place(x=0, y=85-50, width=100, height=200) + + if self.user: + user_info_wrapper = ttk.Frame(wrapper, style="dark.TFrame") + user_info_wrapper.pack(side=ttk.TOP, fill=ttk.X, pady=(35, 0), padx=(10, 10)) + user_info_wrapper.configure(height=50) + + display_name = ttk.Label(user_info_wrapper, text=self.user.display_name, font=("Host Grotesk", 16 if sys.platform != "darwin" else 18, "bold")) + display_name.configure(background=self.root.style.colors.get("dark")) + display_name.place(relx=0, rely=0) + + username = ttk.Label(user_info_wrapper, text=f"{self.user.name}", font=("Host Grotesk", 12 if sys.platform != "darwin" else 14)) + username.configure(background=self.root.style.colors.get("dark"), foreground="lightgrey") + username.place(relx=0, rely=0.42) + + if self.mutual_guilds: + mutual_guilds_subtitle = ttk.Label(wrapper, text="Mutual Guilds", font=("Host Grotesk", 12 if sys.platform != "darwin" else 14, "bold")) + mutual_guilds_subtitle.configure(background=self.root.style.colors.get("dark"), foreground="white") + mutual_guilds_subtitle.pack(side=ttk.TOP, fill=ttk.X, padx=10) + + guilds_wrapper = ScrolledFrame(wrapper, bootstyle="dark.TFrame", autohide=True) + guilds_wrapper.container.configure(style="dark.TFrame") + guilds_wrapper.pack(side=ttk.TOP, fill=ttk.BOTH, pady=(3, 10), padx=(10, 10), expand=True) + guilds_wrapper.columnconfigure(0, weight=1) + + row = 0 + for guild in self.mutual_guilds: + guild_frame = RoundedFrame(guilds_wrapper, radius=5, bootstyle="secondary.TFrame") + guild_frame.grid(row=row, column=0, sticky=ttk.EW, pady=(0, 5)) + + guild_label = ttk.Label(guild_frame, text=guild.name, font=("Host Grotesk", 10 if sys.platform != "darwin" else 12)) + guild_label.configure(background=self.root.style.colors.get("secondary"), foreground="white") + guild_label.grid(row=0, column=0, sticky=ttk.EW, padx=5, pady=5) + guild_frame.grid_columnconfigure(0, weight=1) + + row += 1 + else: + mutual_guilds_subtitle = ttk.Label(wrapper, text="No Mutual Guilds", font=("Host Grotesk", 12 if sys.platform != "darwin" else 14)) + mutual_guilds_subtitle.configure(background=self.root.style.colors.get("dark"), foreground="lightgrey") + mutual_guilds_subtitle.place(relx=.7, rely=0.65, relwidth=1, anchor="center") + + return wrapper + + def clear_messages(self): + if self.messages_textarea: + self.messages_textarea.delete("1.0", "end") + self.messages_formatted = [] + self.messages_all = [] + self.messages_displayed = [] + self.current_search = "" + self.messages_textarea_updating = False + + # Reset placeholder for search bar + # self.search_placeholder_text = "Search a Discord user ID..." if not self.spypet.running else "Search for a message..." + # if self.search_entry: + # self.search_entry.configure(foreground="grey") + # self.search_var.set("") # Resets the actual entry content + # self.search_entry.delete(0, ttk.END) + # self.search_entry.insert(0, self.search_placeholder_text) + + print("Messages fully cleared.") + else: + print("Messages textarea not found. Cannot clear messages.") + + def _on_search_change(self): + query = self.search_var.get().strip() + + if query == self.search_placeholder_text or query == "": + self.current_search = "" + else: + self.current_search = query.lower() + + self._apply_message_filter() + + def _apply_message_filter(self): + try: + self.messages_textarea.delete("1.0", "end") + self.messages_displayed = [] + + for msg in self.messages_all: + formatted = f"[{msg[0]}] [{msg[1]}] [{msg[2]}]: {msg[3]}" + if not self.current_search or self.current_search in formatted.lower(): + self.messages_displayed.append(msg) + self.messages_textarea.insert("end", f"{formatted}\n") + + self.messages_textarea.update_idletasks() + except Exception as e: + print(f"Error applying message filter: {e}") + + def update_messages(self): + if self.messages_textarea_updating: + try: + data = self.spypet.data + current_yview = self.messages_textarea.yview() + at_bottom = current_yview[1] >= 0.999 + + new_msgs = [] + + for guild, channels in data.items(): + for channel, channel_data in channels.items(): + for message in channel_data.get("messages", []): + timestamp = message.get("timestamp", None) + try: + ts = time.strftime("%d/%m/%Y %H:%M:%S", time.strptime(timestamp, "%Y-%m-%dT%H:%M:%S.%f%z")) if timestamp else "Unknown" + except: + ts = timestamp + content = message.get("content", "") + msg = (ts, guild, channel, content) + + if msg not in self.messages_all: + self.messages_all.append(msg) + new_msgs.append(msg) + + for msg in new_msgs: + formatted = f"[{msg[0]}] [{msg[1]}] [{msg[2]}]: {msg[3]}" + if self.current_search.lower() in formatted.lower(): + self.messages_displayed.append(msg) + self.messages_textarea.insert("end", f"{formatted}\n") + + if at_bottom: + self.messages_textarea.see("end") + else: + self.messages_textarea.yview_moveto(current_yview[0]) + + self.messages_textarea.update_idletasks() + except Exception as e: + pass + + self.root.after(500, self.update_messages) + + def _draw_messages_wrapper(self, parent): + wrapper = RoundedFrame(parent, radius=(15, 15, 15, 15), bootstyle="dark.TFrame", custom_size=True) + + self.messages_textarea = ttk.Text(wrapper, wrap="word", height=20, + font=("JetBrainsMono NF Bold", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size) + ) + self.messages_textarea.config( + border=0, + background=self.root.style.colors.get("dark"), + foreground="lightgrey", + highlightcolor=self.root.style.colors.get("dark"), + highlightbackground=self.root.style.colors.get("dark"), + state="normal" + ) + + # Disable insert/delete actions + self.messages_textarea.bind("", lambda e: "break") + self.messages_textarea.bind("", lambda e: "break") # Middle-click paste (Linux) + self.messages_textarea.bind("", lambda e: "break") + self.messages_textarea.bind("", lambda e: "break") + + self.messages_textarea.pack(fill="both", expand=True, padx=5, pady=5) + + return wrapper + + def _update_progress_labels(self, total, user_total): + if self.total_messages_label: + self.total_messages = total + self.total_messages_label.configure(text=f"Total: {total}") + if self.total_user_messages_label: + self.user_total_messages = user_total + self.total_user_messages_label.configure(text=f"Sent by user: {user_total}") + + def _draw_progress_wrapper(self, parent): + wrapper = RoundedFrame(parent, radius=(15, 15, 15, 15), bootstyle="dark.TFrame", custom_size=True) + + self.total_messages_label = ttk.Label(wrapper, text="Total: 0", font=("Host Grotesk", 12 if sys.platform != "darwin" else 14)) + self.total_messages_label.configure(background=self.root.style.colors.get("dark"), foreground="lightgrey") + self.total_messages_label.place(relx=0.05, rely=0.13, relwidth=1, anchor="nw") + + self.total_user_messages_label = ttk.Label(wrapper, text="Sent by user: 0", font=("Host Grotesk", 12 if sys.platform != "darwin" else 14)) + self.total_user_messages_label.configure(background=self.root.style.colors.get("dark"), foreground="lightgrey") + self.total_user_messages_label.place(relx=0.05, rely=0.3 + 0.13, relwidth=1, anchor="nw") + + return wrapper + + def draw_content(self, wrapper): + self.wrapper = wrapper + + header = self._draw_header(wrapper) + header.pack(side=ttk.TOP, fill=ttk.X, pady=(0, 10)) + + self.msgs_logs_wrapper = ttk.Frame(wrapper) + self.msgs_logs_wrapper.pack(side=ttk.LEFT, fill=ttk.BOTH, expand=True, padx=(0, 5)) + self.msgs_logs_wrapper.columnconfigure(0, weight=1) + self.msgs_logs_wrapper.rowconfigure(0, weight=1) + self.msgs_logs_wrapper.rowconfigure(1, weight=0) + + self.messages_wrapper = self._draw_messages_wrapper(self.msgs_logs_wrapper) + self.messages_wrapper.grid(row=0, column=0, sticky="nsew", pady=(0, 10)) + + self.log_wrapper = self._draw_log_wrapper(self.msgs_logs_wrapper) + self.log_wrapper.set_height(150) + self.log_wrapper.grid(row=1, column=0, sticky="ew") + + self.user_progress_wrapper = ttk.Frame(wrapper) + self.user_progress_wrapper.pack(side=ttk.RIGHT, fill=ttk.Y, expand=False, padx=(5, 0)) + + self.progress_wrapper = self._draw_progress_wrapper(self.user_progress_wrapper) + self.progress_wrapper.set_height(55) + self.progress_wrapper.set_width(200) # fixed width + self.progress_wrapper.pack(side=ttk.TOP, fill=ttk.X, pady=(0, 10)) + + self.user_wrapper = self._draw_user_wrapper(self.user_progress_wrapper) + self.user_wrapper.set_width(200) # fixed width + self.user_wrapper.pack(side=ttk.BOTTOM, fill=ttk.BOTH, expand=True) + + self.update() + + if self.spypet.running or len(self.messages_all) > 0: + print("SpyPet is running, updating messages...") + self._check_spypet_running() + self._update_progress_labels(self.spypet.total_messages, self.spypet.user_total_messages) + + if self._disable_reset_button: + self._disable_reset_button() + else: + self._enable_reset_button() \ No newline at end of file diff --git a/gui/pages/tools/tools.py b/gui/pages/tools/tools.py new file mode 100644 index 0000000..a6240c9 --- /dev/null +++ b/gui/pages/tools/tools.py @@ -0,0 +1,70 @@ +import os, sys +import ttkbootstrap as ttk + +from gui.components import RoundedFrame +from gui.pages.tools.spypet_page import SpyPetPage +from gui.pages.tools.message_logger_page import MessageLoggerPage +from gui.pages.tools.user_lookup_page import UserLookupPage + +class ToolsPage: + def __init__(self, root, bot_controller, images, layout): + self.root = root + self.bot_controller = bot_controller + self.images = images + self.layout = layout + + self.spypet_page = SpyPetPage(self, root, bot_controller, images, layout) + self.message_logger_page = MessageLoggerPage(self, root, bot_controller, images, layout) + self.user_lookup_page = UserLookupPage(self, root, bot_controller, images, layout) + + self.pages = [ + { + "name": "spy.pet", + "page": self.spypet_page, + "command": self.draw_spypet + }, + { + "name": "Message Logger", + "page": self.message_logger_page, + "command": self.draw_message_logger + }, + { + "name": "User Lookup", + "page": self.user_lookup_page, + "command": self.draw_user_lookup + } + ] + + def draw_spypet(self): + self.layout.sidebar.set_current_page("tools") + self.layout.clear() + main = self.layout.main() + self.spypet_page.draw(main) + # 🔧 FIX: re-fetch main each time + self.layout.sidebar.set_button_command("tools", self.draw_spypet) + + def draw_message_logger(self): + self.layout.sidebar.set_current_page("tools") + self.layout.clear() + main = self.layout.main() + self.message_logger_page.draw(main) + # 🔧 FIX: re-fetch main each time + self.layout.sidebar.set_button_command("tools", self.draw_message_logger) + + def draw_user_lookup(self): + self.layout.sidebar.set_current_page("tools") + self.layout.clear() + main = self.layout.main() + self.user_lookup_page.draw(main) + # 🔧 FIX: re-fetch main each time + self.layout.sidebar.set_button_command("tools", self.draw_user_lookup) + + def draw(self, parent): + for page in self.pages: + button = ttk.Button( + parent, + text=page["name"], + command=page["command"], + style="dark.TButton" + ) + button.pack(side=ttk.TOP, fill=ttk.X, padx=(20, 20), pady=(0, 10)) \ No newline at end of file diff --git a/gui/pages/tools/user_lookup_page.py b/gui/pages/tools/user_lookup_page.py new file mode 100644 index 0000000..7a09239 --- /dev/null +++ b/gui/pages/tools/user_lookup_page.py @@ -0,0 +1,96 @@ +import sys +import ttkbootstrap as ttk +from gui.components import RoundedFrame, ToolPage + +class UserLookupPage(ToolPage): + def __init__(self, toolspage, root, bot_controller, images, layout): + super().__init__(toolspage, root, bot_controller, images, layout, title="User Lookup", frame=None) + self.search_entry = None # Initialize search entry to None + self.user = None + self.user_avatar = None + self.search_results_widget = None + self.wrapper = None # Initialize wrapper to None + + def _search_user(self, user_id): + user = self.bot_controller.get_user_from_id(user_id) + self.user = user + + avatar_url = self.user.avatar.url if self.user and self.user.avatar else "https://ia600305.us.archive.org/31/items/discordprofilepictures/discordblue.png" + self.user_avatar = self.bot_controller.get_avatar_from_url(avatar_url, size=100, radius=10) + + if self.search_results_widget: + self.search_results_widget.destroy() + self.search_results_widget = self._draw_search_results(self.wrapper) + self.search_results_widget.pack(side=ttk.TOP, fill=ttk.BOTH, expand=True) + + def _draw_search_bar(self, parent): + entry_wrapper = RoundedFrame(parent, radius=(15, 15, 15, 15), bootstyle="dark.TFrame") + # entry_wrapper.pack(fill=ttk.BOTH) + + placeholder_text = "Search a Discord user ID..." + + def on_focus_in(event): + if self.search_entry.get() == placeholder_text: + self.search_entry.delete(0, ttk.END) + self.search_entry.configure(foreground="white") + + def on_focus_out(event): + if self.search_entry.get() == "": + self.search_entry.insert(0, placeholder_text) + self.search_entry.configure(foreground="grey") + + self.search_entry = ttk.Entry(entry_wrapper, bootstyle="dark.TFrame", font=("Host Grotesk",)) + self.search_entry.grid(row=0, column=0, sticky=ttk.EW, padx=(18, 0), pady=10, columnspan=2, ipady=10) + self.search_entry.configure(foreground="grey") + self.search_entry.insert(0, placeholder_text) + self.search_entry.bind("", on_focus_in) + self.search_entry.bind("", on_focus_out) + self.search_entry.bind("", lambda e: self._search_user(self.search_entry.get())) + + search_button = ttk.Label(entry_wrapper, image=self.images.get("search"), style="dark.TButton") + search_button.grid(row=0, column=2, sticky=ttk.E, padx=(0, 10), pady=10) + search_button.bind("", lambda e: self._search_user(self.search_entry.get())) + + entry_wrapper.columnconfigure(1, weight=1) + + return entry_wrapper + + def _draw_search_results(self, parent): + wrapper = RoundedFrame(parent, radius=(15, 15, 15, 15), bootstyle="dark.TFrame") + + if self.user_avatar: + avatar_label = ttk.Label(wrapper, image=self.user_avatar) + avatar_label.configure(background=self.root.style.colors.get("dark")) + avatar_label.grid(row=0, column=1, sticky=ttk.E, padx=15, pady=15) + wrapper.grid_columnconfigure(1, weight=1) + + if self.user: + user_info_wrapper = ttk.Frame(wrapper, style="dark.TFrame") + user_info_wrapper.grid(row=0, column=0, sticky=ttk.NSEW, padx=(15, 0), pady=15) + + display_name = ttk.Label(user_info_wrapper, text=self.user.display_name, font=("Host Grotesk", 16 if sys.platform != "darwin" else 20, "bold")) + display_name.configure(background=self.root.style.colors.get("dark")) + display_name.grid(row=0, column=1, sticky=ttk.W) + + if self.user.bot: + bot_tag = ttk.Label(user_info_wrapper, text="Bot", font=("Host Grotesk", 12 if sys.platform != "darwin" else 14), foreground="red") + bot_tag.configure(background=self.root.style.colors.get("dark")) + bot_tag.grid(row=0, column=2, sticky=ttk.W) + + username = ttk.Label(user_info_wrapper, text=f"@{self.user.name}", font=("Host Grotesk", 12 if sys.platform != "darwin" else 14)) + username.configure(background=self.root.style.colors.get("dark"), foreground="lightgrey") + username.grid(row=1, column=1, sticky=ttk.W) + + user_id = ttk.Label(user_info_wrapper, text=f"ID: {self.user.id}", font=("Host Grotesk", 12 if sys.platform != "darwin" else 14)) + user_id.configure(background=self.root.style.colors.get("dark")) + user_id.grid(row=2, column=1, sticky=ttk.W, pady=(5, 0)) + + return wrapper + + def draw_content(self, wrapper): + self.wrapper = wrapper + search_bar = self._draw_search_bar(self.wrapper) + search_bar.pack(side=ttk.TOP, fill=ttk.X, pady=(0, 10)) + + self.search_results_widget = self._draw_search_results(self.wrapper) + self.search_results_widget.pack(side=ttk.TOP, fill=ttk.BOTH, expand=True) \ No newline at end of file diff --git a/utils/startup_check.py b/utils/startup_check.py index 5051756..892cf3c 100644 --- a/utils/startup_check.py +++ b/utils/startup_check.py @@ -11,7 +11,8 @@ "data/cache", "data", "scripts", - "themes" + "themes", + "data/spypet" ] REQUIRED_FILES = { From 1eb065bf453fe926656ffa51524735056d02c5cd Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Sat, 26 Jul 2025 22:34:57 +0100 Subject: [PATCH 02/23] Make tools page scrollable --- gui/components/tool_page.py | 2 +- gui/main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gui/components/tool_page.py b/gui/components/tool_page.py index f69e1a2..34dd2bf 100644 --- a/gui/components/tool_page.py +++ b/gui/components/tool_page.py @@ -15,7 +15,7 @@ def __init__(self, toolspage, root, bot_controller, images, layout, title, frame def go_back(self): self.layout.sidebar.set_current_page("tools") self.layout.clear() - main = self.layout.main() + main = self.layout.main(scrollable=True) self.toolspage.draw(main) self.layout.sidebar.set_button_command("tools", self.go_back) diff --git a/gui/main.py b/gui/main.py index cef54f0..7aa18b7 100644 --- a/gui/main.py +++ b/gui/main.py @@ -101,7 +101,7 @@ def draw_scripts(self): def draw_tools(self): self.sidebar.set_current_page("tools") self.layout.clear() - main = self.layout.main() + main = self.layout.main(scrollable=True) self.tools_page.draw(main) # def draw_loading(self): From 089033733cdf7ee5c29bd7ad65a6b7eb1b48f693 Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Sat, 26 Jul 2025 22:35:27 +0100 Subject: [PATCH 03/23] Add description to tools --- gui/pages/tools/tools.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/gui/pages/tools/tools.py b/gui/pages/tools/tools.py index a6240c9..fd44d42 100644 --- a/gui/pages/tools/tools.py +++ b/gui/pages/tools/tools.py @@ -19,17 +19,20 @@ def __init__(self, root, bot_controller, images, layout): self.pages = [ { - "name": "spy.pet", + "name": "ghetto spy.pet", + "description": "A tool to look up every message sent by a user you share mutual servers with.", "page": self.spypet_page, "command": self.draw_spypet }, { "name": "Message Logger", + "description": "A tool to log deleted messages from every server you're in.", "page": self.message_logger_page, "command": self.draw_message_logger }, { "name": "User Lookup", + "description": "A tool to look up a user's information.", "page": self.user_lookup_page, "command": self.draw_user_lookup } From ea96e83018049689c179db93f421e9a3cf73cc64 Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Sat, 26 Jul 2025 22:41:19 +0100 Subject: [PATCH 04/23] Make tool button a better styled roundedframe, still needs work --- gui/components/rounded_frame.py | 7 ++++++- gui/pages/tools/tools.py | 26 +++++++++++++++++++------- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/gui/components/rounded_frame.py b/gui/components/rounded_frame.py index 2fa5673..cd3f36a 100644 --- a/gui/components/rounded_frame.py +++ b/gui/components/rounded_frame.py @@ -100,4 +100,9 @@ def set_width(self, width): self.configure(width=width) self.pack_propagate(False) # prevent geometry propagation self.grid_propagate(False) - self.on_resize() \ No newline at end of file + self.on_resize() + + def bind(self, sequence=None, func=None, add=None): + if sequence == "": + return super().bind(sequence, self.on_resize, add=add) + return super().bind(sequence, func, add=add) \ No newline at end of file diff --git a/gui/pages/tools/tools.py b/gui/pages/tools/tools.py index fd44d42..aab17ed 100644 --- a/gui/pages/tools/tools.py +++ b/gui/pages/tools/tools.py @@ -63,11 +63,23 @@ def draw_user_lookup(self): self.layout.sidebar.set_button_command("tools", self.draw_user_lookup) def draw(self, parent): + + for page in self.pages: - button = ttk.Button( - parent, - text=page["name"], - command=page["command"], - style="dark.TButton" - ) - button.pack(side=ttk.TOP, fill=ttk.X, padx=(20, 20), pady=(0, 10)) \ No newline at end of file + page_wrapper = RoundedFrame(parent, radius=10, bootstyle="dark.TFrame") + page_wrapper.pack(fill="x", expand=True, pady=(0, 10)) + page_wrapper.bind("", lambda e, cmd=page["command"]: cmd()) + + page_title = ttk.Label(page_wrapper, text=page["name"], font=("Host Grotesk", 14 if sys.platform != "darwin" else 20, "bold")) + page_title.configure(background=self.root.style.colors.get("dark")) + page_title.grid(row=0, column=0, sticky=ttk.NSEW, padx=15, pady=(15, 5)) + page_title.bind("", lambda e, cmd=page["command"]: cmd()) + + page_description = ttk.Label(page_wrapper, text=page["description"], font=("Host Grotesk", 12 if sys.platform != "darwin" else 16), wraplength=450) + page_description.configure(background=self.root.style.colors.get("dark")) + page_description.grid(row=1, column=0, sticky=ttk.NSEW, padx=15, pady=(0, 15)) + page_description.bind("", lambda e, cmd=page["command"]: cmd()) + + # page_icon = ttk.Label(self.header, image=self.header_icon) + # page_icon.configure(background=self.root.style.colors.get("secondary")) + # page_icon.grid(row=0, column=2, sticky=ttk.E, padx=(0, 15), pady=15) \ No newline at end of file From 6c70efc24467d92949bf5d17e1b831fd230357ea Mon Sep 17 00:00:00 2001 From: qrexpy <117946311+qrexpy@users.noreply.github.com> Date: Sun, 27 Jul 2025 20:22:16 +0300 Subject: [PATCH 05/23] Enhance image processing and error handling in avatar retrieval and resizing --- bot/controller.py | 23 +++++++++++++---------- gui/helpers/images.py | 11 +++++++++++ gui/pages/tools/spypet_page.py | 5 ++++- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/bot/controller.py b/bot/controller.py index deff7e1..9d2b63f 100644 --- a/bot/controller.py +++ b/bot/controller.py @@ -217,16 +217,19 @@ def get_user_from_id(self, user_id): return self.bot.get_user(user_id) def get_avatar_from_url(self, url, size=50, radius=5): - url = url.split("?")[0] - if url.endswith(".gif"): - url = url.replace(".gif", ".png") - response = requests.get(url) - if response.status_code == 200: - image = Image.open(BytesIO(response.content)) - # image = image.resize((size, size)) - image = resize_and_sharpen(image, (size, size)) - image = imgembed.add_corners(image, radius) - return ImageTk.PhotoImage(image) + try: + url = url.split("?")[0] + if url.endswith(".gif"): + url = url.replace(".gif", ".png") + response = requests.get(url, timeout=10) + if response.status_code == 200: + image = Image.open(BytesIO(response.content)) + # image = image.resize((size, size)) + image = resize_and_sharpen(image, (size, size)) + image = imgembed.add_corners(image, radius) + return ImageTk.PhotoImage(image) + except Exception as e: + print(f"Error processing avatar from URL {url}: {e}") return None diff --git a/gui/helpers/images.py b/gui/helpers/images.py index 8ec77e0..8eed6a0 100644 --- a/gui/helpers/images.py +++ b/gui/helpers/images.py @@ -8,9 +8,20 @@ def resize_and_sharpen(image, size): try: + # Convert palette images to RGB to avoid "cannot filter palette images" error + if image.mode == 'P': + image = image.convert('RGB') + elif image.mode == 'RGBA': + # Keep RGBA for transparency support + pass + elif image.mode not in ['RGB', 'RGBA']: + # Convert other modes to RGB + image = image.convert('RGB') + resized_image = image.resize(size, Image.LANCZOS) except Exception as e: print("error resizing image:", e) + return image try: sharpened_image = ImageEnhance.Sharpness(resized_image).enhance(2.0) except Exception as e: diff --git a/gui/pages/tools/spypet_page.py b/gui/pages/tools/spypet_page.py index 49af174..044a841 100644 --- a/gui/pages/tools/spypet_page.py +++ b/gui/pages/tools/spypet_page.py @@ -440,6 +440,9 @@ def _on_search_change(self): def _apply_message_filter(self): try: + if not self.messages_textarea: + return + self.messages_textarea.delete("1.0", "end") self.messages_displayed = [] @@ -454,7 +457,7 @@ def _apply_message_filter(self): print(f"Error applying message filter: {e}") def update_messages(self): - if self.messages_textarea_updating: + if self.messages_textarea_updating and self.messages_textarea: try: data = self.spypet.data current_yview = self.messages_textarea.yview() From 2e859a96d5591c249486857f7dd26aac97a02e24 Mon Sep 17 00:00:00 2001 From: qrexpy <117946311+qrexpy@users.noreply.github.com> Date: Thu, 31 Jul 2025 00:37:47 +0300 Subject: [PATCH 06/23] Move message logging to the tools tab and improve UI for displaying deleted messages --- bot/bot.py | 3 +- gui/pages/home.py | 204 +------------------------ gui/pages/tools/message_logger_page.py | 199 +++++++++++++++++++++++- 3 files changed, 200 insertions(+), 206 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 78d3369..598aa84 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -179,7 +179,8 @@ async def on_message_delete(self, message): if message.author.id == self.user.id: return delete_time = time.time() - self.controller.gui.home_page.add_discord_log(message.author, message, delete_time) + if self.controller.gui and hasattr(self.controller.gui, 'tools_page'): + self.controller.gui.tools_page.message_logger_page.add_discord_log(message.author, message, delete_time) def run_bot(self): try: diff --git a/gui/pages/home.py b/gui/pages/home.py index f2a38ee..4109f6d 100644 --- a/gui/pages/home.py +++ b/gui/pages/home.py @@ -19,15 +19,6 @@ def __init__(self, root, bot_controller, _restart_bot): self.images = Images() self.cfg = Config() - self.discord_logs_wrapper = None - self.discord_logs_footer = None - # self.discord_logs_textbox = None - self.discord_logs_inner_wrapper = None - self.discord_logs_max = False - self.discord_logs_max_min_btn = None - self.discord_logs_frame = None - self.discord_logs = [] - self.details_wrapper = None self.friends_label = None self.guilds_label = None @@ -188,189 +179,10 @@ def _draw_bot_details(self, parent): self.latency_label.grid(row=4, column=0, sticky=ttk.W, padx=(10, 0), pady=(0, 10)) def _update_wraplength(self, event=None): - if self.discord_logs_frame: - try: - new_width = self.discord_logs_frame.winfo_width() - 30 # Adjust for padding - for widget in self.discord_logs_frame.winfo_children(): - if isinstance(widget, ttk.Label): - widget.configure(wraplength=max(new_width, 100)) - except: - pass - - def _display_log(self, log_entry): - author, message, delete_time = log_entry - try: - - frame = RoundedFrame(self.discord_logs_frame, radius=(8, 8, 8, 8), bootstyle="secondary.TFrame", parent_background=self.root.style.colors.get("dark")) - frame.pack(fill=ttk.X, pady=(0, 5), padx=(0, 15)) - - try: - if author.id not in self.avatars: - self.avatars[author.id] = self.bot_controller.get_avatar_from_url(author.avatar.url, size=50, radius=5) - - avatar_image = self.avatars.get(author.id) - except: - url = "https://ia600305.us.archive.org/31/items/discordprofilepictures/discordblue.png" - avatar_image = self.bot_controller.get_avatar_from_url(url, size=50, radius=5) - - avatar = ttk.Label(frame, image=avatar_image) - avatar.configure(background=self.root.style.colors.get("secondary")) - avatar.grid(row=0, column=0, sticky=ttk.NW, padx=(5, 5), rowspan=3, pady=5) - - author_label = ttk.Label(frame, text=author.display_name, font=("Host Grotesk", 12 if sys.platform != "darwin" else 14, "bold")) - author_label.configure(background=self.root.style.colors.get("secondary")) - author_label.grid(row=0, column=1, sticky=ttk.NW, padx=(0, 10), pady=(5, 0)) - - message_content = message.content[:250] + "..." if len(message.content) > 250 else message.content - - message_label = ttk.Label(frame, text=message_content, font=("Host Grotesk", 10 if sys.platform != "darwin" else 12), wraplength=350) - message_label.configure(background=self.root.style.colors.get("secondary")) - message_label.grid(row=1, column=1, sticky=ttk.NW, padx=(0, 10)) - - channel_label_text = f"Deleted in DMs" if isinstance(message.channel, discord.DMChannel) else f"Deleted in {message.guild.name} > #{message.channel.name}" - channel_label = ttk.Label(frame, text=channel_label_text, font=("Host Grotesk", 8 if sys.platform != "darwin" else 10, "italic")) - channel_label.configure(background=self.root.style.colors.get("secondary"), foreground="lightgrey") - channel_label.grid(row=2, column=1, sticky=ttk.NW, padx=(0, 10), pady=(0, 5)) - - ttk.Label(frame, text="", background=self.root.style.colors.get("secondary")).grid(row=2, column=2, sticky=ttk.E) - frame.grid_columnconfigure(1, weight=1) - frame.grid_columnconfigure(2, weight=1) - - # # Function to update wraplength - # def update_wraplength(event): - # new_width = frame.winfo_width() - 60 # Adjusting for padding and avatar width - # message_label.configure(wraplength=new_width if new_width > 100 else 100) - - # # Bind window resize event - # self.root.bind("", update_wraplength) - - self.discord_logs_canvas.yview_moveto(1) - except: - pass - - def add_discord_log(self, author, message, delete_time): - log_entry = (author, message, delete_time) - self.discord_logs.append(log_entry) - self._display_log(log_entry) - - def _load_discord_logs(self): - for log_entry in self.discord_logs: - self._display_log(log_entry) + # This method can be used for future wraplength updates if needed + pass - try: - self._update_canvas_width(self.discord_logs_canvas, self.discord_logs_wrapper) - self.discord_logs_canvas.yview_moveto(1) - except: - pass - - def _clear_discord_logs(self): - for widget in self.discord_logs_frame.winfo_children(): - widget.destroy() - - self.discord_logs = [] - self.avatars = {} - - # Clear the canvas - self.discord_logs_canvas.delete("all") - - # Reset the scroll region - self.discord_logs_canvas.configure(scrollregion=self.discord_logs_canvas.bbox("all")) - - # Update the canvas width - self._update_canvas_width(self.discord_logs_canvas, self.discord_logs_wrapper) - - def _max_discord_logs(self): - self.root.update_idletasks() - self.discord_logs_max = True - self.discord_logs_max_min_btn.configure(image=self.images.get("min")) - self.discord_logs_max_min_btn.bind("", lambda e: self._min_discord_logs()) - # self.discord_logs_textbox.configure(height=18) - - # self.discord_logs_wrapper.pack_forget() - # self.discord_logs_footer.pack_forget() - self.details_wrapper.pack_forget() - - self.discord_logs_wrapper.pack(fill=ttk.BOTH, expand=True, pady=(10, 0)) - # self.discord_logs_footer.pack(fill=ttk.BOTH, expand=False) - self.root.update_idletasks() - - def _min_discord_logs(self): - self.root.update_idletasks() - self.discord_logs_max = False - self.discord_logs_max_min_btn.configure(image=self.images.get("max")) - self.discord_logs_max_min_btn.bind("", lambda e: self._max_discord_logs()) - # self.discord_logs_textbox.configure(height=10) - - self.discord_logs_wrapper.pack_forget() - # self.discord_logs_footer.pack_forget() - self.details_wrapper.pack(fill=ttk.BOTH, expand=False, pady=(10, 0)) - self.discord_logs_wrapper.pack(fill=ttk.BOTH, expand=True, pady=(5, 0)) - # self.discord_logs_footer.pack(fill=ttk.BOTH, expand=False) - self.root.update_idletasks() - - def _update_canvas_width(self, canvas, logs_wrapper): - canvas.itemconfig(self.canvas_window, width=logs_wrapper.winfo_width() - 24) - - def _draw_discord_logs(self, parent): - if self.restart: - wrapper = RoundedFrame(parent, radius=(15, 15, 15, 15), bootstyle="dark.TFrame") - wrapper.pack(fill=ttk.BOTH, expand=True, pady=(5, 0)) - return wrapper - - wrapper = ttk.Frame(parent) - wrapper.pack(fill=ttk.BOTH, expand=True, pady=(5, 0)) - - header_wrapper = RoundedFrame(wrapper, radius=(15, 15, 0, 0), style="secondary.TFrame") - header_wrapper.pack(fill=ttk.BOTH, expand=False) - - title = ttk.Label(header_wrapper, text="Deleted Messages", font=("Host Grotesk", 14 if sys.platform != "darwin" else 16, "bold")) - title.configure(background=self.root.style.colors.get("secondary")) - title.grid(row=0, column=0, sticky=ttk.W, padx=10, pady=10) - header_wrapper.columnconfigure(1, weight=1) - - clear_btn = ttk.Label(header_wrapper, image=self.images.get("trash"), font=("Host Grotesk", 12 if sys.platform != "darwin" else 14, "bold")) - clear_btn.configure(background=self.root.style.colors.get("secondary"), foreground="white") - clear_btn.bind("", lambda e: self._clear_discord_logs()) - clear_btn.bind("", lambda e: clear_btn.configure(foreground="lightgrey")) - clear_btn.bind("", lambda e: clear_btn.configure(foreground="white")) - clear_btn.grid(row=0, column=2, sticky=ttk.E, pady=10) - - self.discord_logs_max_min_btn = ttk.Label(header_wrapper, image=self.images.get("max") if not self.discord_logs_max else self.images.get("min"), anchor="center") - self.discord_logs_max_min_btn.configure(background=self.root.style.colors.get("secondary")) - self.discord_logs_max_min_btn.bind("", lambda e: self._max_discord_logs() if not self.discord_logs_max else lambda e: self._min_discord_logs()) - self.discord_logs_max_min_btn.grid(row=0, column=3, sticky=ttk.E, padx=(10, 13), pady=8) - - logs_wrapper = RoundedFrame(wrapper, radius=(0, 0, 15, 15), style="dark.TFrame") - logs_wrapper.pack(fill=ttk.BOTH, expand=True) - - self.discord_logs_inner_wrapper = ttk.Frame(logs_wrapper) - self.discord_logs_inner_wrapper.pack(fill=ttk.BOTH, expand=True, padx=10, pady=10) - - canvas = ttk.Canvas(self.discord_logs_inner_wrapper) - scrollbar = ttk.Scrollbar(self.discord_logs_inner_wrapper, orient="vertical", command=canvas.yview) - scroll_frame = ttk.Frame(canvas, style="dark.TFrame") - - scroll_frame.bind( - "", - lambda e: canvas.configure(scrollregion=canvas.bbox("all")) - ) - - self.canvas_window = canvas.create_window((0, 0), window=scroll_frame, anchor="nw") - - canvas.configure(yscrollcommand=scrollbar.set, background=self.root.style.colors.get("dark")) - - canvas.pack(side="left", fill="both", expand=True) - scrollbar.pack(side="right", fill="y") - - self.discord_logs_frame = scroll_frame # Store this for adding logs later - self.discord_logs_canvas = canvas # Store this for updating the width later - - # Schedule the update of canvas width after the UI has been rendered - canvas.after(100, lambda: self._update_canvas_width(canvas, logs_wrapper)) - - return wrapper - def draw(self, parent, restart=False, start=False): self.restart = restart or start self.restart_title_text = "Ghost is starting" if start else "Ghost is restarting" @@ -380,18 +192,6 @@ def draw(self, parent, restart=False, start=False): self.details_wrapper = self._draw_details_wrapper(parent) self._draw_account_details(self.details_wrapper) self._draw_bot_details(self.details_wrapper) - self.discord_logs_wrapper = self._draw_discord_logs(parent) - - self._load_discord_logs() - - if self.discord_logs_max: - self._max_discord_logs() - - try: - self._update_canvas_width(self.discord_logs_canvas, self.discord_logs_wrapper) - self.discord_logs_canvas.yview_moveto(1) - except: - pass self._update_bot_details() self._update_account_details() \ No newline at end of file diff --git a/gui/pages/tools/message_logger_page.py b/gui/pages/tools/message_logger_page.py index e1fd577..5f344cf 100644 --- a/gui/pages/tools/message_logger_page.py +++ b/gui/pages/tools/message_logger_page.py @@ -1,12 +1,205 @@ +import webbrowser, discord, sys, time import ttkbootstrap as ttk +import tkinter.font as tkFont +from ttkbootstrap.scrolled import ScrolledFrame from gui.components import RoundedFrame, ToolPage +from gui.helpers import Images +from utils.config import VERSION, CHANGELOG, MOTD, Config class MessageLoggerPage(ToolPage): def __init__(self, toolspage, root, bot_controller, images, layout): super().__init__(toolspage, root, bot_controller, images, layout, title="Message Logger") + + self.discord_logs_wrapper = None + self.discord_logs_inner_wrapper = None + self.discord_logs_frame = None + self.discord_logs = [] + self.discord_logs_canvas = None + self.canvas_window = None + self.avatars = {} + + def draw_navigation(self, parent): + wrapper = ttk.Frame(parent) + + back_button = ttk.Label(wrapper, image=self.images.get("left-chevron")) + back_button.bind("", lambda e: self.go_back()) + back_button.grid(row=0, column=1, sticky=ttk.W, padx=(0, 10)) + + page_name = ttk.Label(wrapper, text=self.title, font=("Host Grotesk", 16, "bold")) + page_name.grid(row=0, column=2, sticky=ttk.W) + + clear_btn = ttk.Label(wrapper, image=self.images.get("trash")) + clear_btn.configure(foreground="white") + clear_btn.bind("", lambda e: self._clear_discord_logs()) + clear_btn.bind("", lambda e: clear_btn.configure(foreground="lightgrey")) + clear_btn.bind("", lambda e: clear_btn.configure(foreground="white")) + clear_btn.grid(row=0, column=3, sticky=ttk.E, padx=(20, 0)) + + wrapper.grid_columnconfigure(2, weight=1) + + return wrapper + + def add_discord_log(self, author, message, delete_time): + log_entry = (author, message, delete_time) + self.discord_logs.append(log_entry) + + if self.discord_logs_frame and self.discord_logs_canvas: + self._display_log(log_entry) + self.root.after_idle(lambda: self._update_canvas_after_add()) + + def _update_canvas_after_add(self): + try: + if self.discord_logs_canvas and self.discord_logs_frame: + self.discord_logs_canvas.configure(scrollregion=self.discord_logs_canvas.bbox("all")) + + if self.discord_logs_wrapper: + self._update_canvas_width(self.discord_logs_canvas, self.discord_logs_wrapper) + + self.discord_logs_canvas.yview_moveto(1) + except: + pass + + def _load_discord_logs(self): + for log_entry in self.discord_logs: + self._display_log(log_entry) + + try: + self._update_canvas_width(self.discord_logs_canvas, self.discord_logs_wrapper) + self.discord_logs_canvas.yview_moveto(1) + except: + pass + + def _clear_discord_logs(self): + if not self.discord_logs_frame: + return + + for widget in self.discord_logs_frame.winfo_children(): + widget.destroy() + + self.discord_logs = [] + self.avatars = {} + + if self.discord_logs_canvas: + self.discord_logs_canvas.configure(scrollregion="0 0 0 0") + + if self.discord_logs_wrapper: + self._update_canvas_width(self.discord_logs_canvas, self.discord_logs_wrapper) + + if hasattr(self.bot_controller, 'gui') and hasattr(self.bot_controller.gui, 'tools_page'): + try: + if hasattr(self.bot_controller.gui.tools_page, 'message_logger_page'): + pass + except: + pass + + def _update_canvas_width(self, canvas, logs_wrapper): + try: + available_width = logs_wrapper.winfo_width() - 35 + canvas.itemconfig(self.canvas_window, width=available_width) + except: + pass + + def _update_wraplength(self, event=None): + if self.discord_logs_frame: + try: + new_width = self.discord_logs_frame.winfo_width() - 60 + for widget in self.discord_logs_frame.winfo_children(): + if hasattr(widget, 'winfo_children'): + for child in widget.winfo_children(): + if isinstance(child, ttk.Label) and hasattr(child, 'configure'): + if hasattr(child, 'cget') and child.cget('wraplength') > 0: + child.configure(wraplength=max(300, new_width - 80)) + except: + pass + + def _display_log(self, log_entry): + if not self.discord_logs_frame: + return + + author, message, delete_time = log_entry + + try: + frame = RoundedFrame(self.discord_logs_frame, radius=(8, 8, 8, 8), bootstyle="secondary.TFrame", parent_background=self.root.style.colors.get("dark")) + frame.pack(fill=ttk.X, pady=(0, 8)) + + content_frame = ttk.Frame(frame, style="secondary.TFrame") + content_frame.pack(fill=ttk.BOTH, expand=True, padx=(12, 20), pady=10) + + author_frame = ttk.Frame(content_frame, style="secondary.TFrame") + author_frame.pack(fill=ttk.X, pady=(0, 8)) + + if author.avatar: + if author.id not in self.avatars: + try: + self.avatars[author.id] = self.bot_controller.get_avatar_from_url(str(author.avatar.url), size=28, radius=14) + except: + self.avatars[author.id] = None + + if self.avatars[author.id]: + avatar_label = ttk.Label(author_frame, image=self.avatars[author.id]) + avatar_label.configure(background=self.root.style.colors.get("secondary")) + avatar_label.pack(side=ttk.LEFT, padx=(0, 10)) + + author_label = ttk.Label(author_frame, text=author.display_name, font=("Host Grotesk", 12, "bold")) + author_label.configure(background=self.root.style.colors.get("secondary"), foreground="white") + author_label.pack(side=ttk.LEFT) + + formatted_time = time.strftime("%H:%M:%S", time.localtime(delete_time)) + time_label = ttk.Label(author_frame, text=formatted_time, font=("Host Grotesk", 10)) + time_label.configure(background=self.root.style.colors.get("secondary"), foreground="lightgrey") + time_label.pack(side=ttk.RIGHT, padx=(10, 15)) + + channel_label_text = f"Deleted in DMs" if isinstance(message.channel, discord.DMChannel) else f"Deleted in {message.guild.name} > #{message.channel.name}" + channel_label = ttk.Label(content_frame, text=channel_label_text, font=("Host Grotesk", 9)) + channel_label.configure(background=self.root.style.colors.get("secondary"), foreground="lightblue") + channel_label.pack(fill=ttk.X, pady=(0, 8)) + + if message.content: + content_label = ttk.Label(content_frame, text=message.content, font=("Host Grotesk", 10), wraplength=450) + content_label.configure(background=self.root.style.colors.get("secondary"), foreground="white") + content_label.pack(fill=ttk.X) + else: + content_label = ttk.Label(content_frame, text="[No text content]", font=("Host Grotesk", 10, "italic")) + content_label.configure(background=self.root.style.colors.get("secondary"), foreground="grey") + content_label.pack(fill=ttk.X) + + if message.attachments: + attachments_label = ttk.Label(content_frame, text=f"📎 {len(message.attachments)} attachment(s)", font=("Host Grotesk", 9, "italic")) + attachments_label.configure(background=self.root.style.colors.get("secondary"), foreground="lightblue") + attachments_label.pack(fill=ttk.X, pady=(4, 0)) + + except Exception as e: + print(f"Error displaying log: {e}") def draw_content(self, wrapper): - title = ttk.Label(wrapper, text=self.title, font=("Host Grotesk", 16, "bold")) - title.pack(side=ttk.TOP, anchor=ttk.W, padx=(20, 0), pady=(20, 0)) + self.root.bind("", self._update_wraplength) + + content_wrapper = RoundedFrame(wrapper, radius=15, style="dark.TFrame") + content_wrapper.pack(fill=ttk.BOTH, expand=True, pady=(20, 0)) + + self.discord_logs_inner_wrapper = ttk.Frame(content_wrapper) + self.discord_logs_inner_wrapper.pack(fill=ttk.BOTH, expand=True, padx=10, pady=10) + + canvas = ttk.Canvas(self.discord_logs_inner_wrapper) + scrollbar = ttk.Scrollbar(self.discord_logs_inner_wrapper, orient="vertical", command=canvas.yview) + scroll_frame = ttk.Frame(canvas, style="dark.TFrame") + + scroll_frame.bind( + "", + lambda e: canvas.configure(scrollregion=canvas.bbox("all")) + ) + + self.canvas_window = canvas.create_window((0, 0), window=scroll_frame, anchor="nw") + + canvas.configure(yscrollcommand=scrollbar.set, background=self.root.style.colors.get("dark")) + + canvas.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + self.discord_logs_frame = scroll_frame + self.discord_logs_canvas = canvas + self.discord_logs_wrapper = content_wrapper - # Any additional widgets go here \ No newline at end of file + canvas.after(100, lambda: self._update_canvas_width(canvas, content_wrapper)) + + self._load_discord_logs() \ No newline at end of file From db5ffadf9372829313d3384108491dfe02d480e4 Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Wed, 30 Jul 2025 23:38:43 +0100 Subject: [PATCH 07/23] Improve font sizes for different OS and padding --- gui/pages/tools/message_logger_page.py | 29 ++++++++++++-------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/gui/pages/tools/message_logger_page.py b/gui/pages/tools/message_logger_page.py index 5f344cf..869c016 100644 --- a/gui/pages/tools/message_logger_page.py +++ b/gui/pages/tools/message_logger_page.py @@ -120,7 +120,7 @@ def _display_log(self, log_entry): try: frame = RoundedFrame(self.discord_logs_frame, radius=(8, 8, 8, 8), bootstyle="secondary.TFrame", parent_background=self.root.style.colors.get("dark")) - frame.pack(fill=ttk.X, pady=(0, 8)) + frame.pack(fill=ttk.X, pady=(0, 8), padx=(0, 8)) content_frame = ttk.Frame(frame, style="secondary.TFrame") content_frame.pack(fill=ttk.BOTH, expand=True, padx=(12, 20), pady=10) @@ -138,34 +138,34 @@ def _display_log(self, log_entry): if self.avatars[author.id]: avatar_label = ttk.Label(author_frame, image=self.avatars[author.id]) avatar_label.configure(background=self.root.style.colors.get("secondary")) - avatar_label.pack(side=ttk.LEFT, padx=(0, 10)) + avatar_label.pack(side=ttk.LEFT, padx=(0, 5)) - author_label = ttk.Label(author_frame, text=author.display_name, font=("Host Grotesk", 12, "bold")) + author_label = ttk.Label(author_frame, text=author.display_name, font=("Host Grotesk", 12 if sys.platform != "darwin" else 14, "bold")) author_label.configure(background=self.root.style.colors.get("secondary"), foreground="white") author_label.pack(side=ttk.LEFT) formatted_time = time.strftime("%H:%M:%S", time.localtime(delete_time)) - time_label = ttk.Label(author_frame, text=formatted_time, font=("Host Grotesk", 10)) + time_label = ttk.Label(author_frame, text=formatted_time, font=("Host Grotesk", 8 if sys.platform != "darwin" else 10)) time_label.configure(background=self.root.style.colors.get("secondary"), foreground="lightgrey") - time_label.pack(side=ttk.RIGHT, padx=(10, 15)) + time_label.pack(side=ttk.LEFT, padx=(5, 0), pady=(2, 0)) channel_label_text = f"Deleted in DMs" if isinstance(message.channel, discord.DMChannel) else f"Deleted in {message.guild.name} > #{message.channel.name}" - channel_label = ttk.Label(content_frame, text=channel_label_text, font=("Host Grotesk", 9)) - channel_label.configure(background=self.root.style.colors.get("secondary"), foreground="lightblue") + channel_label = ttk.Label(content_frame, text=channel_label_text, font=("Host Grotesk", 8 if sys.platform != "darwin" else 10, "italic")) + channel_label.configure(background=self.root.style.colors.get("secondary"), foreground="lightgrey") channel_label.pack(fill=ttk.X, pady=(0, 8)) if message.content: - content_label = ttk.Label(content_frame, text=message.content, font=("Host Grotesk", 10), wraplength=450) + content_label = ttk.Label(content_frame, text=message.content, font=("Host Grotesk", 10 if sys.platform != "darwin" else 12), wraplength=420) content_label.configure(background=self.root.style.colors.get("secondary"), foreground="white") content_label.pack(fill=ttk.X) else: - content_label = ttk.Label(content_frame, text="[No text content]", font=("Host Grotesk", 10, "italic")) + content_label = ttk.Label(content_frame, text="[No text content]", font=("Host Grotesk", 10 if sys.platform != "darwin" else 12, "italic")) content_label.configure(background=self.root.style.colors.get("secondary"), foreground="grey") content_label.pack(fill=ttk.X) if message.attachments: attachments_label = ttk.Label(content_frame, text=f"📎 {len(message.attachments)} attachment(s)", font=("Host Grotesk", 9, "italic")) - attachments_label.configure(background=self.root.style.colors.get("secondary"), foreground="lightblue") + attachments_label.configure(background=self.root.style.colors.get("secondary"), foreground="lightgrey") attachments_label.pack(fill=ttk.X, pady=(4, 0)) except Exception as e: @@ -174,11 +174,8 @@ def _display_log(self, log_entry): def draw_content(self, wrapper): self.root.bind("", self._update_wraplength) - content_wrapper = RoundedFrame(wrapper, radius=15, style="dark.TFrame") - content_wrapper.pack(fill=ttk.BOTH, expand=True, pady=(20, 0)) - - self.discord_logs_inner_wrapper = ttk.Frame(content_wrapper) - self.discord_logs_inner_wrapper.pack(fill=ttk.BOTH, expand=True, padx=10, pady=10) + self.discord_logs_inner_wrapper = ttk.Frame(wrapper) + self.discord_logs_inner_wrapper.pack(fill=ttk.BOTH, expand=True, padx=10, pady=(10, 10)) canvas = ttk.Canvas(self.discord_logs_inner_wrapper) scrollbar = ttk.Scrollbar(self.discord_logs_inner_wrapper, orient="vertical", command=canvas.yview) @@ -198,7 +195,7 @@ def draw_content(self, wrapper): self.discord_logs_frame = scroll_frame self.discord_logs_canvas = canvas - self.discord_logs_wrapper = content_wrapper + self.discord_logs_wrapper = wrapper canvas.after(100, lambda: self._update_canvas_width(canvas, content_wrapper)) From b68b9a787f848281be9389cb08d78c82d7c0f640 Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Wed, 30 Jul 2025 23:38:56 +0100 Subject: [PATCH 08/23] Add mouse wheel scrolling support --- gui/pages/tools/message_logger_page.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/gui/pages/tools/message_logger_page.py b/gui/pages/tools/message_logger_page.py index 869c016..f338190 100644 --- a/gui/pages/tools/message_logger_page.py +++ b/gui/pages/tools/message_logger_page.py @@ -47,6 +47,12 @@ def add_discord_log(self, author, message, delete_time): self._display_log(log_entry) self.root.after_idle(lambda: self._update_canvas_after_add()) + def _on_mousewheel(self, event): + if sys.platform == 'darwin': + self.discord_logs_canvas.yview_scroll(-1 * int(event.delta), "units") + else: + self.discord_logs_canvas.yview_scroll(-1 * int(event.delta / 120), "units") + def _update_canvas_after_add(self): try: if self.discord_logs_canvas and self.discord_logs_frame: @@ -197,6 +203,14 @@ def draw_content(self, wrapper): self.discord_logs_canvas = canvas self.discord_logs_wrapper = wrapper - canvas.after(100, lambda: self._update_canvas_width(canvas, content_wrapper)) - + canvas.after(100, lambda: self._update_canvas_width(canvas, wrapper)) + + self.discord_logs_canvas.bind("", lambda e: self.discord_logs_canvas.bind_all("", self._on_mousewheel)) + self.discord_logs_canvas.bind("", lambda e: self.discord_logs_canvas.unbind_all("")) + self.discord_logs_canvas.bind("", self._update_wraplength) + self.discord_logs_canvas.bind("", self._on_mousewheel) + self.discord_logs_canvas.bind("", self._on_mousewheel) + self.discord_logs_canvas.bind("", self._on_mousewheel) + + self._load_discord_logs() \ No newline at end of file From 9cd7e449022e8f11c364ace8d67ffb38d0e62785 Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Wed, 30 Jul 2025 23:44:50 +0100 Subject: [PATCH 09/23] Remove update wrap length, will come back to this and improve responsiveness --- gui/pages/tools/message_logger_page.py | 1 - 1 file changed, 1 deletion(-) diff --git a/gui/pages/tools/message_logger_page.py b/gui/pages/tools/message_logger_page.py index f338190..e98a49f 100644 --- a/gui/pages/tools/message_logger_page.py +++ b/gui/pages/tools/message_logger_page.py @@ -207,7 +207,6 @@ def draw_content(self, wrapper): self.discord_logs_canvas.bind("", lambda e: self.discord_logs_canvas.bind_all("", self._on_mousewheel)) self.discord_logs_canvas.bind("", lambda e: self.discord_logs_canvas.unbind_all("")) - self.discord_logs_canvas.bind("", self._update_wraplength) self.discord_logs_canvas.bind("", self._on_mousewheel) self.discord_logs_canvas.bind("", self._on_mousewheel) self.discord_logs_canvas.bind("", self._on_mousewheel) From 761d22c57a3b0814ff99dd1ed846ce99efa9600b Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Thu, 31 Jul 2025 00:14:27 +0100 Subject: [PATCH 10/23] Better styled tools page list in style of settings page with nice hover effect --- data/icons/chevron-right-solid.png | Bin 0 -> 7343 bytes gui/helpers/images.py | 3 +- gui/pages/tools/tools.py | 47 ++++++++++++++++++++--------- 3 files changed, 34 insertions(+), 16 deletions(-) create mode 100644 data/icons/chevron-right-solid.png diff --git a/data/icons/chevron-right-solid.png b/data/icons/chevron-right-solid.png new file mode 100644 index 0000000000000000000000000000000000000000..97ec7ca5d5d2b9a781fe27d66ea7ddae7be0698c GIT binary patch literal 7343 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 literal 0 HcmV?d00001 diff --git a/gui/helpers/images.py b/gui/helpers/images.py index 8eed6a0..a2fdc3a 100644 --- a/gui/helpers/images.py +++ b/gui/helpers/images.py @@ -73,7 +73,7 @@ def _load_images(self): ICON_CONFIG = { "bigger": ["scripts"], - "small": ["trash", "github", "restart", "checkmark", "left-chevron", "file-signature", "trash-white"], + "small": ["trash", "github", "restart", "checkmark", "left-chevron", "file-signature", "trash-white", "right-chevron"], "tiny": ["submit", "max", "min", "search"], "smaller": ["folder-open", "plus", "reset", "play", "stop"], "logo": ["ghost-logo"], @@ -104,6 +104,7 @@ def _load_images(self): "folder-open": "data/icons/folder-open-solid.png", "file-signature": "data/icons/file-signature-solid.png", "left-chevron": "data/icons/chevron-left-solid.png", + "right-chevron": "data/icons/chevron-right-solid.png", "tools": "data/icons/screwdriver-wrench-solid.png", "reset": "data/icons/rotate-left-solid.png", "play": "data/icons/play-solid.png", diff --git a/gui/pages/tools/tools.py b/gui/pages/tools/tools.py index aab17ed..19fec2d 100644 --- a/gui/pages/tools/tools.py +++ b/gui/pages/tools/tools.py @@ -12,6 +12,7 @@ def __init__(self, root, bot_controller, images, layout): self.bot_controller = bot_controller self.images = images self.layout = layout + self.hover_colour = "#282a2a" self.spypet_page = SpyPetPage(self, root, bot_controller, images, layout) self.message_logger_page = MessageLoggerPage(self, root, bot_controller, images, layout) @@ -43,7 +44,6 @@ def draw_spypet(self): self.layout.clear() main = self.layout.main() self.spypet_page.draw(main) - # 🔧 FIX: re-fetch main each time self.layout.sidebar.set_button_command("tools", self.draw_spypet) def draw_message_logger(self): @@ -51,7 +51,6 @@ def draw_message_logger(self): self.layout.clear() main = self.layout.main() self.message_logger_page.draw(main) - # 🔧 FIX: re-fetch main each time self.layout.sidebar.set_button_command("tools", self.draw_message_logger) def draw_user_lookup(self): @@ -59,27 +58,45 @@ def draw_user_lookup(self): self.layout.clear() main = self.layout.main() self.user_lookup_page.draw(main) - # 🔧 FIX: re-fetch main each time self.layout.sidebar.set_button_command("tools", self.draw_user_lookup) - def draw(self, parent): - + def _bind_hover_effects(self, widget, targets, hover_bg, normal_bg): + def on_enter(_): + for target in targets: + if isinstance(target, RoundedFrame): + target.set_background(background=hover_bg) + else: + target.configure(background=hover_bg) + + def on_leave(_): + for target in targets: + if isinstance(target, RoundedFrame): + target.set_background(background=normal_bg) + else: + target.configure(background=normal_bg) + + widget.bind("", on_enter) + widget.bind("", on_leave) + def draw(self, parent): for page in self.pages: - page_wrapper = RoundedFrame(parent, radius=10, bootstyle="dark.TFrame") + page_wrapper = RoundedFrame(parent, radius=10, bootstyle="secondary.TFrame") page_wrapper.pack(fill="x", expand=True, pady=(0, 10)) page_wrapper.bind("", lambda e, cmd=page["command"]: cmd()) page_title = ttk.Label(page_wrapper, text=page["name"], font=("Host Grotesk", 14 if sys.platform != "darwin" else 20, "bold")) - page_title.configure(background=self.root.style.colors.get("dark")) - page_title.grid(row=0, column=0, sticky=ttk.NSEW, padx=15, pady=(15, 5)) + page_title.configure(background=self.root.style.colors.get("secondary")) + page_title.grid(row=0, column=0, sticky=ttk.NSEW, padx=15, pady=(15, 15)) page_title.bind("", lambda e, cmd=page["command"]: cmd()) - page_description = ttk.Label(page_wrapper, text=page["description"], font=("Host Grotesk", 12 if sys.platform != "darwin" else 16), wraplength=450) - page_description.configure(background=self.root.style.colors.get("dark")) - page_description.grid(row=1, column=0, sticky=ttk.NSEW, padx=15, pady=(0, 15)) - page_description.bind("", lambda e, cmd=page["command"]: cmd()) + # page_description = ttk.Label(page_wrapper, text=page["description"], font=("Host Grotesk", 12 if sys.platform != "darwin" else 16), wraplength=450) + # page_description.configure(background=self.root.style.colors.get("secondary")) + # page_description.grid(row=1, column=0, sticky=ttk.NSEW, padx=15, pady=(0, 15)) + # page_description.bind("", lambda e, cmd=page["command"]: cmd()) + + page_icon = ttk.Label(page_wrapper, image=self.images.get("right-chevron")) + page_icon.configure(background=self.root.style.colors.get("secondary")) + page_icon.grid(row=0, column=1, sticky=ttk.E, padx=(0, 20), pady=15) - # page_icon = ttk.Label(self.header, image=self.header_icon) - # page_icon.configure(background=self.root.style.colors.get("secondary")) - # page_icon.grid(row=0, column=2, sticky=ttk.E, padx=(0, 15), pady=15) \ No newline at end of file + page_wrapper.grid_columnconfigure(1, weight=1) + self._bind_hover_effects(page_wrapper, [page_title, page_wrapper, page_icon], self.hover_colour, self.root.style.colors.get("secondary")) \ No newline at end of file From 638195a54c13ce0e1213a55902269280e340c4c0 Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Fri, 1 Aug 2025 08:08:10 +0100 Subject: [PATCH 11/23] Adjust corner radius of tool frame to match everywhere else --- gui/pages/tools/tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/pages/tools/tools.py b/gui/pages/tools/tools.py index 19fec2d..cd166fc 100644 --- a/gui/pages/tools/tools.py +++ b/gui/pages/tools/tools.py @@ -80,7 +80,7 @@ def on_leave(_): def draw(self, parent): for page in self.pages: - page_wrapper = RoundedFrame(parent, radius=10, bootstyle="secondary.TFrame") + page_wrapper = RoundedFrame(parent, radius=15, bootstyle="secondary.TFrame") page_wrapper.pack(fill="x", expand=True, pady=(0, 10)) page_wrapper.bind("", lambda e, cmd=page["command"]: cmd()) From 311f5ee2e3dc25d80074367359821eecad8286db Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Fri, 15 Aug 2025 17:48:04 +0100 Subject: [PATCH 12/23] Changed script page to use cupcake editor. It's slow and a WIP! If you want to implement it uncomment the sections in `pages/scripts.py` --- gui/pages/script.py | 86 ++++++++------------------------------------ gui/pages/scripts.py | 12 +++++++ 2 files changed, 27 insertions(+), 71 deletions(-) diff --git a/gui/pages/script.py b/gui/pages/script.py index 0f593f7..db90453 100644 --- a/gui/pages/script.py +++ b/gui/pages/script.py @@ -1,9 +1,10 @@ -import os +import sys import ttkbootstrap as ttk from utils.files import get_application_support from gui.helpers.images import Images from gui.components.rounded_frame import RoundedFrame +from cupcake import Editor, Languages class ScriptPage: def __init__(self, root, script): @@ -24,8 +25,9 @@ def _get_script_content(self): return file.read() def _save_script(self): - with open(get_application_support() + f"/scripts/{self.script}", "w") as file: - file.write(self.editor.get("1.0", ttk.END)) + # with open(get_application_support() + f"/scripts/{self.script}", "w") as file: + # file.write(self.editor.content.get("1.0", ttk.END)) + self.editor.save() def _draw_header(self, parent): wrapper = ttk.Frame(parent) @@ -39,79 +41,21 @@ def _draw_header(self, parent): return wrapper - def _update_line_numbers(self, event=None): - """Update line numbers in the left sidebar.""" - line_numbers = "" - for i in range(1, int(self.editor.index('end-1c').split('.')[0]) + 1): - line_numbers += f"{i}\n" - self.linenumbers.config(state="normal") - self.linenumbers.delete('1.0', 'end') - self.linenumbers.insert('1.0', line_numbers) - self.linenumbers.config(state="disabled") - - def move_cursor_to_line(self, line_number): - """Move the cursor to a specific line and set focus.""" - self.editor.mark_set("insert", f"{line_number}.0") # line_number.0 represents the start of the line - self.editor.see(f"{line_number}.0") # Scroll to the line if it's out of view - self.editor.focus_set() # Ensure focus is set after moving the cursor - - def _sync_scroll(self, *args): - """Sync the scrolling of line numbers and editor.""" - if args[0] == "moveto" or args[0] == "scroll": - # Sync both editor and line numbers - self.linenumbers.yview(*args) - self.editor.yview(*args) - def draw(self, parent): header = self._draw_header(parent) header.pack(fill=ttk.X, pady=(0, 20)) editor_wrapper = RoundedFrame(parent, radius=(15, 15, 15, 15), bootstyle="dark.TFrame") editor_wrapper.pack(fill=ttk.BOTH, expand=True) - - # Create a frame to hold both the line numbers and editor - text_container = ttk.Frame(editor_wrapper) - text_container.pack(fill=ttk.BOTH, expand=True) - # Line number widget (disabled to prevent editing) - self.linenumbers = ttk.Text(text_container, width=4, font=("JetBrainsMono NF Regular", 12), state="disabled") - self.linenumbers.pack(side=ttk.LEFT, fill=ttk.Y) - - # Main text editor - self.editor = ttk.Text(text_container, wrap="word", font=("JetBrainsMono NF Regular", 12)) + self.editor = Editor( + editor_wrapper, + language=Languages.PYTHON, + darkmode=True, + font=("JetBrainsMono NF Regular", 10 if sys.platform != "darwin" else 12), + path=get_application_support() + f"/scripts/{self.script}", + showpath=False + ) self.editor.pack(fill=ttk.BOTH, expand=True) - - # Insert script content - self.editor.insert(ttk.END, self._get_script_content()) - - # Ensure the editor is editable - self.editor.configure(state="normal") - self.editor.config(tabs=("4c",)) - - # Create a scrollbar and link it to the editor and line numbers - self.text_scrollbar = ttk.Scrollbar(editor_wrapper, orient="vertical", command=self._sync_scroll) - self.text_scrollbar.pack(side=ttk.RIGHT, fill=ttk.Y) - - # Set the scrollbars for both widgets - self.editor.config(yscrollcommand=self.text_scrollbar.set) - self.linenumbers.config(yscrollcommand=self.text_scrollbar.set) - - # Focus handling - def set_focus(event): - # Set focus when clicking inside the editor - self.editor.focus_set() - - # Focus handling - self.editor.bind("", set_focus) # Set focus on click - self.editor.bind("", lambda e: self.editor.focus_set()) # Set focus when mouse enters - self.editor.bind("", lambda e: self.editor.focus_set()) # Set focus when it gains focus - - # Attach events to sync line numbers - self.editor.bind("", self._update_line_numbers) - self.editor.bind("", self._update_line_numbers) - - # Initialize line numbers - self._update_line_numbers() - - # Example usage: Move cursor to a specific line (for instance, line 5) - self.move_cursor_to_line(5) + content = self.editor.content + content.insert("end", self._get_script_content()) \ No newline at end of file diff --git a/gui/pages/scripts.py b/gui/pages/scripts.py index 3a7271b..b48ce5c 100644 --- a/gui/pages/scripts.py +++ b/gui/pages/scripts.py @@ -4,7 +4,12 @@ from ttkbootstrap.scrolled import ScrolledFrame from ttkbootstrap.dialogs import Messagebox from gui.components import RoundedFrame + +# Uncomment the below to enable the dedicated script page. +# Please be aware this is a work in progress and the current state of the page is laggy and sometimes unresponsive. + # from gui.pages.script import ScriptPage + from utils.config import Config from utils.files import open_path_in_explorer, get_application_support from utils.defaults import DEFAULT_SCRIPT @@ -19,12 +24,19 @@ def __init__(self, root, bot_controller, images): self.script_frames = [] def _open_editor(self, script): + # Uncomment the below to enable the dedicated script page. + # Please be aware this is a work in progress and the current state of the page is laggy and sometimes unresponsive. + # self.gui.sidebar.set_current_page("scripts") # self.gui.layout.clear() # main = self.gui.layout.main() # script_page = ScriptPage(self.gui, script) # script_page.draw(main) + + # Please comment out the below to enable the dedicated script page! + # Please be aware this is a work in progress and the current state of the page is laggy and sometimes unresponsive. + if sys.platform == "darwin": try: os.system(f"code '{get_application_support()}/scripts/{script}'") From 8c999a3646183205d4e0e225b44995673c1622b7 Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Fri, 15 Aug 2025 18:40:00 +0100 Subject: [PATCH 13/23] Speed up image embed --- bot/helpers/imgembed.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/helpers/imgembed.py b/bot/helpers/imgembed.py index 6e5767b..bb116e2 100644 --- a/bot/helpers/imgembed.py +++ b/bot/helpers/imgembed.py @@ -285,7 +285,10 @@ def draw(self): def save(self): path = f"embed-{random.randint(1000, 9999)}.png" # comment this out if youre running the script directly # path = "embed.png" # uncomment this if youre running the script directly - self.draw().save(path) + final = self.draw() + final.thumbnail((self.width // 2, self.height // 2), Image.LANCZOS) + # final = final.resize((self.width // 2, self.height // 2), Image.LANCZOS) + final.save(path, optimize=True, quality=20) return path From 181d026fa67c5ea0f63694b5c86e4940ede391da Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Thu, 28 Aug 2025 09:54:48 +0100 Subject: [PATCH 14/23] Add copying to console #37 --- gui/components/console.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/gui/components/console.py b/gui/components/console.py index 66573f1..a577dd5 100644 --- a/gui/components/console.py +++ b/gui/components/console.py @@ -96,7 +96,7 @@ def _draw_footer(self, parent): clear_btn.configure(background=self.root.style.colors.get("secondary")) clear_btn.bind("", lambda e: self.clear()) clear_btn.grid(row=0, column=3, padx=(10, 8), pady=5, sticky="e") - + def _draw_main(self, parent): wrapper = RoundedFrame(parent, radius=15, bootstyle="dark.TFrame") wrapper.pack(side="top", fill="both", expand=True) @@ -112,12 +112,11 @@ def _draw_main(self, parent): highlightbackground=self.root.style.colors.get("dark"), state="normal" ) - - # Disable insert/delete actions - self.textarea.bind("", lambda e: "break") - self.textarea.bind("", lambda e: "break") # Middle-click paste (Linux) - self.textarea.bind("", lambda e: "break") - self.textarea.bind("", lambda e: "break") + + self.textarea.bind_all( + "" if sys.platform != "darwin" else "", + lambda _: self.textarea.event_generate("<>") + ) self.textarea.pack(fill="both", expand=True, padx=5, pady=5) self._load_tags() From 6cdfc6ebe728b61de1db622fe016ff67384a027c Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Thu, 28 Aug 2025 09:55:12 +0100 Subject: [PATCH 15/23] Fix default theme image by using new domain --- utils/defaults.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/defaults.py b/utils/defaults.py index 24e2440..05fd6aa 100644 --- a/utils/defaults.py +++ b/utils/defaults.py @@ -49,7 +49,7 @@ DEFAULT_THEME = { "title": "ghost selfbot", "emoji": "\ud83d\udc7b", - "image": "https://ghost.benny.fun/assets/ghost.png", + "image": "https://ghostt.cc/assets/ghost.png", "colour": "#575757", "footer": "ghost aint dead" } From 70b086fd6e561811352e0d7569e21b5e285988ab Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Thu, 28 Aug 2025 09:55:27 +0100 Subject: [PATCH 16/23] Automatically acknowledge rich embed webhook messages --- bot/helpers/cmdhelper.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/helpers/cmdhelper.py b/bot/helpers/cmdhelper.py index 86edffb..7b6c218 100644 --- a/bot/helpers/cmdhelper.py +++ b/bot/helpers/cmdhelper.py @@ -100,6 +100,8 @@ async def rich_embed(ctx, embed): webhook_client.send(embed=embed.to_dict()) async for message in webhook_channel.history(limit=1): + await message.ack() + try: resp = requests.post( f"https://discord.com/api/v9/channels/{ctx.channel.id}/messages", From faab03bcbcf375d6b11944504a4baff37ccf0898 Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Thu, 28 Aug 2025 11:40:24 +0100 Subject: [PATCH 17/23] Create a new class for message log entry and add automatic resizing to content text height and frame width and height. Added selecting and copying of content text too. Fixes #37 --- gui/components/tools/__init__.py | 1 + gui/components/tools/message_log_entry.py | 144 ++++++++++++++++++++++ gui/pages/tools/message_logger_page.py | 73 +++-------- 3 files changed, 163 insertions(+), 55 deletions(-) create mode 100644 gui/components/tools/__init__.py create mode 100644 gui/components/tools/message_log_entry.py diff --git a/gui/components/tools/__init__.py b/gui/components/tools/__init__.py new file mode 100644 index 0000000..90522ab --- /dev/null +++ b/gui/components/tools/__init__.py @@ -0,0 +1 @@ +from .message_log_entry import MessageLogEntry \ No newline at end of file diff --git a/gui/components/tools/message_log_entry.py b/gui/components/tools/message_log_entry.py new file mode 100644 index 0000000..efd1f22 --- /dev/null +++ b/gui/components/tools/message_log_entry.py @@ -0,0 +1,144 @@ +import sys +import time +import math +import ttkbootstrap as ttk +import discord + +from gui.components import RoundedFrame +import tkinter.font as tkFont + +class MessageLogEntry: + def __init__(self, parent, root, bot_controller, avatars, log_entry): + self.parent = parent + self.root = root + self.bot_controller = bot_controller + self.avatars = avatars + self.author, self.message, self.delete_time = log_entry + + self.frame = None + self.content_label = None + self.selected = False + + self._draw() + + def _autoresize_text(self, text_widget): + px_width = text_widget.winfo_width() + if px_width <= 1: + text_widget.after(50, lambda: self._autoresize_text(text_widget)) + return + + font = tkFont.Font(font=("Host Grotesk", 10 if sys.platform != "darwin" else 12)) + char_width = font.measure("0") + max_chars = max(1, px_width // char_width) + 15 + text = text_widget.get("1.0", "end-1c") + + lines = text.split("\n") + total_lines = 0 + for line in lines: + total_lines += math.ceil(len(line) / max_chars) or 1 + + text_widget.configure(height=total_lines) + + def _draw(self): + try: + # Outer frame + self.frame = RoundedFrame( + self.parent, + radius=(8, 8, 8, 8), + bootstyle="secondary.TFrame", + parent_background=self.root.style.colors.get("dark") + ) + self.frame.pack(fill=ttk.X, pady=(0, 8), padx=(0, 8), expand=True) + + # Inner content + content_frame = ttk.Frame(self.frame, style="secondary.TFrame") + content_frame.pack(fill=ttk.BOTH, expand=True, padx=(12, 20), pady=10) + + # Author line + author_frame = ttk.Frame(content_frame, style="secondary.TFrame") + author_frame.pack(fill=ttk.X, pady=(0, 8)) + + # Avatar + if self.author.avatar: + if self.author.id not in self.avatars: + try: + self.avatars[self.author.id] = self.bot_controller.get_avatar_from_url( + str(self.author.avatar.url), size=28, radius=14 + ) + except Exception: + self.avatars[self.author.id] = None + + if self.avatars[self.author.id]: + avatar_label = ttk.Label(author_frame, image=self.avatars[self.author.id]) + avatar_label.configure(background=self.root.style.colors.get("secondary")) + avatar_label.pack(side=ttk.LEFT, padx=(0, 5)) + + # Author name + author_label = ttk.Label( + author_frame, + text=self.author.display_name, + font=("Host Grotesk", 12 if sys.platform != "darwin" else 14, "bold") + ) + author_label.configure(background=self.root.style.colors.get("secondary"), foreground="white") + author_label.pack(side=ttk.LEFT) + + # Time + formatted_time = time.strftime("%H:%M:%S", time.localtime(self.delete_time)) + time_label = ttk.Label( + author_frame, + text=formatted_time, + font=("Host Grotesk", 8 if sys.platform != "darwin" else 10) + ) + time_label.configure(background=self.root.style.colors.get("secondary"), foreground="lightgrey") + time_label.pack(side=ttk.LEFT, padx=(5, 0), pady=(2, 0)) + + # Channel info + channel_label_text = ( + f"Deleted in DMs" + if isinstance(self.message.channel, discord.DMChannel) + else f"Deleted in {self.message.guild.name} > #{self.message.channel.name}" + ) + channel_label = ttk.Label( + content_frame, + text=channel_label_text, + font=("Host Grotesk", 8 if sys.platform != "darwin" else 10, "italic") + ) + channel_label.configure(background=self.root.style.colors.get("secondary"), foreground="lightgrey") + channel_label.pack(fill=ttk.X, pady=(0, 8)) + + self.content_label = ttk.Text(content_frame, font=("Host Grotesk", 10 if sys.platform != "darwin" else 12), wrap="word", state="normal") + self.content_label.insert("1.0", self.message.content or "[No text content]") + self.content_label.configure( + background=self.root.style.colors.get("secondary"), + foreground="white" if self.message.content else "grey", + border=False, + borderwidth=0, + highlightthickness=0 + ) + + self.content_label.pack(fill=ttk.X) + + self.root.after(100, lambda: self._autoresize_text(self.content_label)) + self.content_label.bind("", lambda e: self._autoresize_text(self.content_label)) + self.content_label.bind_all( + "" if sys.platform != "darwin" else "", + lambda _: self.content_label.event_generate("<>") + ) + + # Attachments + if self.message.attachments: + attachments_label = ttk.Label( + content_frame, + text=f"📎 {len(self.message.attachments)} attachment(s)", + font=("Host Grotesk", 9, "italic") + ) + attachments_label.configure(background=self.root.style.colors.get("secondary"), foreground="lightgrey") + attachments_label.pack(fill=ttk.X, pady=(4, 0)) + + self.frame.bind("", self.on_click) + + except Exception as e: + print(f"Error displaying log: {e}") + + def on_click(self, event): + pass \ No newline at end of file diff --git a/gui/pages/tools/message_logger_page.py b/gui/pages/tools/message_logger_page.py index e98a49f..04e4aca 100644 --- a/gui/pages/tools/message_logger_page.py +++ b/gui/pages/tools/message_logger_page.py @@ -3,6 +3,7 @@ import tkinter.font as tkFont from ttkbootstrap.scrolled import ScrolledFrame from gui.components import RoundedFrame, ToolPage +from gui.components.tools.message_log_entry import MessageLogEntry from gui.helpers import Images from utils.config import VERSION, CHANGELOG, MOTD, Config @@ -15,6 +16,7 @@ def __init__(self, toolspage, root, bot_controller, images, layout): self.discord_logs_frame = None self.discord_logs = [] self.discord_logs_canvas = None + self.logs = [] self.canvas_window = None self.avatars = {} @@ -121,61 +123,15 @@ def _update_wraplength(self, event=None): def _display_log(self, log_entry): if not self.discord_logs_frame: return - - author, message, delete_time = log_entry - - try: - frame = RoundedFrame(self.discord_logs_frame, radius=(8, 8, 8, 8), bootstyle="secondary.TFrame", parent_background=self.root.style.colors.get("dark")) - frame.pack(fill=ttk.X, pady=(0, 8), padx=(0, 8)) - - content_frame = ttk.Frame(frame, style="secondary.TFrame") - content_frame.pack(fill=ttk.BOTH, expand=True, padx=(12, 20), pady=10) - - author_frame = ttk.Frame(content_frame, style="secondary.TFrame") - author_frame.pack(fill=ttk.X, pady=(0, 8)) - - if author.avatar: - if author.id not in self.avatars: - try: - self.avatars[author.id] = self.bot_controller.get_avatar_from_url(str(author.avatar.url), size=28, radius=14) - except: - self.avatars[author.id] = None - - if self.avatars[author.id]: - avatar_label = ttk.Label(author_frame, image=self.avatars[author.id]) - avatar_label.configure(background=self.root.style.colors.get("secondary")) - avatar_label.pack(side=ttk.LEFT, padx=(0, 5)) - - author_label = ttk.Label(author_frame, text=author.display_name, font=("Host Grotesk", 12 if sys.platform != "darwin" else 14, "bold")) - author_label.configure(background=self.root.style.colors.get("secondary"), foreground="white") - author_label.pack(side=ttk.LEFT) - - formatted_time = time.strftime("%H:%M:%S", time.localtime(delete_time)) - time_label = ttk.Label(author_frame, text=formatted_time, font=("Host Grotesk", 8 if sys.platform != "darwin" else 10)) - time_label.configure(background=self.root.style.colors.get("secondary"), foreground="lightgrey") - time_label.pack(side=ttk.LEFT, padx=(5, 0), pady=(2, 0)) - - channel_label_text = f"Deleted in DMs" if isinstance(message.channel, discord.DMChannel) else f"Deleted in {message.guild.name} > #{message.channel.name}" - channel_label = ttk.Label(content_frame, text=channel_label_text, font=("Host Grotesk", 8 if sys.platform != "darwin" else 10, "italic")) - channel_label.configure(background=self.root.style.colors.get("secondary"), foreground="lightgrey") - channel_label.pack(fill=ttk.X, pady=(0, 8)) - - if message.content: - content_label = ttk.Label(content_frame, text=message.content, font=("Host Grotesk", 10 if sys.platform != "darwin" else 12), wraplength=420) - content_label.configure(background=self.root.style.colors.get("secondary"), foreground="white") - content_label.pack(fill=ttk.X) - else: - content_label = ttk.Label(content_frame, text="[No text content]", font=("Host Grotesk", 10 if sys.platform != "darwin" else 12, "italic")) - content_label.configure(background=self.root.style.colors.get("secondary"), foreground="grey") - content_label.pack(fill=ttk.X) - - if message.attachments: - attachments_label = ttk.Label(content_frame, text=f"📎 {len(message.attachments)} attachment(s)", font=("Host Grotesk", 9, "italic")) - attachments_label.configure(background=self.root.style.colors.get("secondary"), foreground="lightgrey") - attachments_label.pack(fill=ttk.X, pady=(4, 0)) - - except Exception as e: - print(f"Error displaying log: {e}") + + entry = MessageLogEntry( + parent=self.discord_logs_frame, + root=self.root, + bot_controller=self.bot_controller, + avatars=self.avatars, + log_entry=log_entry + ) + self.logs.append(entry) # keep track of all logs def draw_content(self, wrapper): self.root.bind("", self._update_wraplength) @@ -194,10 +150,17 @@ def draw_content(self, wrapper): self.canvas_window = canvas.create_window((0, 0), window=scroll_frame, anchor="nw") + def resize_canvas(event): + # Match the inner frame to the canvas width + canvas.itemconfig(self.canvas_window, width=event.width) + + canvas.bind("", resize_canvas) + canvas.configure(yscrollcommand=scrollbar.set, background=self.root.style.colors.get("dark")) canvas.pack(side="left", fill="both", expand=True) scrollbar.pack(side="right", fill="y") + self.discord_logs_frame = scroll_frame self.discord_logs_canvas = canvas From 33fe5081e52216194918b0f11d31fc29f6bbcadc Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Thu, 28 Aug 2025 11:45:48 +0100 Subject: [PATCH 18/23] Update version number to 4.1.0-dev --- utils/config/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/config/__init__.py b/utils/config/__init__.py index b745755..7d99cf3 100644 --- a/utils/config/__init__.py +++ b/utils/config/__init__.py @@ -4,7 +4,7 @@ from .config import Config from .token import Token -VERSION = "4.0.1" +VERSION = "4.1.0-dev" PRODUCTION = False MOTD = "gotta love tkinter ;)" CHANGELOG = """""" \ No newline at end of file From 77de06486dcf8bffb323656e69f3b32d4bee3ce5 Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Sat, 30 Aug 2025 12:23:15 +0100 Subject: [PATCH 19/23] Add selecting and copying text in console and messages textarea --- gui/pages/tools/spypet_page.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/gui/pages/tools/spypet_page.py b/gui/pages/tools/spypet_page.py index 044a841..f68b741 100644 --- a/gui/pages/tools/spypet_page.py +++ b/gui/pages/tools/spypet_page.py @@ -32,6 +32,10 @@ def __init__(self, toolspage, root, bot_controller, images, layout): self.messages_displayed = [] self.total_messages = 0 self.user_total_messages = 0 + self.focused_textarea = None + + def _set_focused_textarea(self, widget): + self.focused_textarea = widget def _redraw_user_wrapper(self): if self.user_wrapper: @@ -333,12 +337,6 @@ def _draw_log_wrapper(self, parent): highlightbackground=self.root.style.colors.get("dark"), state="normal" ) - - # Disable insert/delete actions - self.textarea.bind("", lambda e: "break") - self.textarea.bind("", lambda e: "break") # Middle-click paste (Linux) - self.textarea.bind("", lambda e: "break") - self.textarea.bind("", lambda e: "break") self.textarea.pack(fill="both", expand=True, padx=5, pady=5) self._load_tags() @@ -511,12 +509,6 @@ def _draw_messages_wrapper(self, parent): highlightbackground=self.root.style.colors.get("dark"), state="normal" ) - - # Disable insert/delete actions - self.messages_textarea.bind("", lambda e: "break") - self.messages_textarea.bind("", lambda e: "break") # Middle-click paste (Linux) - self.messages_textarea.bind("", lambda e: "break") - self.messages_textarea.bind("", lambda e: "break") self.messages_textarea.pack(fill="both", expand=True, padx=5, pady=5) @@ -584,4 +576,12 @@ def draw_content(self, wrapper): if self._disable_reset_button: self._disable_reset_button() else: - self._enable_reset_button() \ No newline at end of file + self._enable_reset_button() + + self.messages_textarea.bind("", lambda e: self._set_focused_textarea(self.messages_textarea)) + self.messages_textarea.bind("", lambda e: self.textarea.tag_remove("sel", "1.0", "end")) + + self.textarea.bind("", lambda e: self._set_focused_textarea(self.textarea)) + self.textarea.bind("", lambda e: self.messages_textarea.tag_remove("sel", "1.0", "end")) + + self.root.bind_all("", lambda e: self.focused_textarea.event_generate("<>") if self.focused_textarea else None) \ No newline at end of file From 9659bf756d299ca0e23c83361aae7c59b85cc419 Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Sat, 30 Aug 2025 12:29:17 +0100 Subject: [PATCH 20/23] Disable the reset button when spypet has been reset --- gui/pages/tools/spypet_page.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gui/pages/tools/spypet_page.py b/gui/pages/tools/spypet_page.py index f68b741..5bba515 100644 --- a/gui/pages/tools/spypet_page.py +++ b/gui/pages/tools/spypet_page.py @@ -182,6 +182,8 @@ def _reset_spypet(self): self.clear_messages() self._check_spypet_running() self._update_progress_labels(0, 0) + + self.root.after(150, self._disable_reset_button) def _draw_start_stop_button(self, parent): def _hover_enter(_): From 4d48f3c11f35b826ec0f94e5bda3f206927f16aa Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Sat, 30 Aug 2025 12:40:38 +0100 Subject: [PATCH 21/23] Added extra and better fail safe --- gui/pages/tools/spypet_page.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/gui/pages/tools/spypet_page.py b/gui/pages/tools/spypet_page.py index 5bba515..1f0a035 100644 --- a/gui/pages/tools/spypet_page.py +++ b/gui/pages/tools/spypet_page.py @@ -439,10 +439,10 @@ def _on_search_change(self): self._apply_message_filter() def _apply_message_filter(self): + if not self.messages_textarea or len(self.messages_all) == 0: + return + try: - if not self.messages_textarea: - return - self.messages_textarea.delete("1.0", "end") self.messages_displayed = [] @@ -453,8 +453,8 @@ def _apply_message_filter(self): self.messages_textarea.insert("end", f"{formatted}\n") self.messages_textarea.update_idletasks() - except Exception as e: - print(f"Error applying message filter: {e}") + except: + pass def update_messages(self): if self.messages_textarea_updating and self.messages_textarea: From bfb8cad02ef469a2634af60a7cfa6b13dfd6407b Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Sun, 11 Jan 2026 13:45:45 +0000 Subject: [PATCH 22/23] Fix scripts page not hiding restart warning --- bot/bot.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/bot.py b/bot/bot.py index 8a7c0f5..b043bb7 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -123,6 +123,7 @@ async def on_message(self, message): await self.add_cog(ScriptCog(self)) console.print_info(f"Loaded script: {script_name}") + self.controller.add_startup_script(script) except Exception as e: console.print_error(f"Error loading script: {script_name} - {e}") From ec2f3eb26c3ce54d4b09797ebeade9c2a9f0eb98 Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Sun, 11 Jan 2026 14:03:32 +0000 Subject: [PATCH 23/23] Remove close/quit warning --- gui/components/sidebar.py | 14 ++++++++------ gui/main.py | 18 ++++++++++-------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/gui/components/sidebar.py b/gui/components/sidebar.py index 326c13a..22edcc6 100644 --- a/gui/components/sidebar.py +++ b/gui/components/sidebar.py @@ -1,4 +1,4 @@ -import os +import os, sys import ttkbootstrap as ttk from ttkbootstrap.dialogs import Messagebox from gui.helpers import Images @@ -48,11 +48,13 @@ def set_button_command(self, page_name, command): raise ValueError(f"Button '{page_name}' not found in sidebar.") def _quit(self): - if str(Messagebox.yesno("Are you sure you want to quit?", title="Ghost")).lower() == "yes": - if os.name == "nt": - os.kill(os.getpid(), 9) - else: - os._exit(0) + # if str(Messagebox.yesno("Are you sure you want to quit?", title="Ghost")).lower() == "yes": + # if os.name == "nt": + # os.kill(os.getpid(), 9) + # else: + # os._exit(0) + self.root.destroy() + sys.exit(0) def disable(self): allowed = ["home", "console"] diff --git a/gui/main.py b/gui/main.py index 7aa18b7..df5f80f 100644 --- a/gui/main.py +++ b/gui/main.py @@ -178,14 +178,16 @@ def run(self): self.root.mainloop() def quit(self): - if str(Messagebox.yesno("Are you sure you want to quit?", title="Ghost")).lower() == "yes": - # uninstall_fonts() - # if os.name == "nt": - # os.kill(os.getpid(), 9) - # else: - # os._exit(0) - self.root.destroy() - sys.exit(0) + # if str(Messagebox.yesno("Are you sure you want to quit?", title="Ghost")).lower() == "yes": + # # uninstall_fonts() + # # if os.name == "nt": + # # os.kill(os.getpid(), 9) + # # else: + # # os._exit(0) + # self.root.destroy() + # sys.exit(0) + self.root.destroy() + sys.exit(0) def run_on_main_thread(self, func, *args, **kwargs): self.root.after(0, lambda: func(*args, **kwargs))