import wx import irc.client import threading import re import time import traceback from collections import defaultdict from datetime import datetime import socket import logging import queue import os import sys from PrivacyNoticeDialog import PrivacyNoticeDialog from IRCPanel import IRCPanel from AboutDialog import AboutDialog from NotesDialog import NotesDialog from ScanWizard import ScanWizardDialog # Set up logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) def get_resource_path(relative_path): """Get absolute path to resource, works for dev and for PyInstaller""" try: # PyInstaller creates a temp folder and stores path in _MEIPASS base_path = sys._MEIPASS except Exception: base_path = os.path.abspath(".") return os.path.join(base_path, relative_path) class UIUpdate: """Thread-safe UI update container""" def __init__(self, callback, *args, **kwargs): self.callback = callback self.args = args self.kwargs = kwargs class IRCFrame(wx.Frame): def __init__(self): super().__init__(None, title="wxIRC", size=(1200, 700)) # Apply white theme self.apply_white_theme() # Show privacy notice first self.show_privacy_notice() self.reactor = None self.connection = None self.reactor_thread = None self.connection_lock = threading.RLock() self.is_connecting = False self.is_disconnecting = False self.ui_update_queue = queue.Queue() self.ui_timer = None self.channels = {} self.channel_users = defaultdict(list) self.current_channel = None self.nick = "" self.server = "" self.port = 6667 self.highlights = [] self.auto_join_channels = [] self.away = False self.timestamps = True self.notes_data = defaultdict(dict) # User color mapping - darker colors for white theme self.user_colors = {} self.available_colors = [ wx.Colour(178, 34, 34), # Firebrick red wx.Colour(0, 100, 0), # Dark green wx.Colour(0, 0, 139), # Dark blue wx.Colour(139, 69, 19), # Saddle brown wx.Colour(139, 0, 139), # Dark magenta wx.Colour(0, 139, 139), # Dark cyan wx.Colour(210, 105, 30), # Chocolate wx.Colour(75, 0, 130), # Indigo wx.Colour(178, 34, 34), # Firebrick wx.Colour(0, 128, 128), # Teal wx.Colour(72, 61, 139), # Dark slate blue wx.Colour(139, 0, 0), # Dark red ] self.color_index = 0 self.motd_lines = [] self.collecting_motd = False self.setup_irc_handlers() self.create_menubar() self.setup_ui() # Start UI update timer self.ui_timer = wx.Timer(self) self.Bind(wx.EVT_TIMER, self.process_ui_updates, self.ui_timer) self.ui_timer.Start(50) # Process UI updates every 50ms self.CreateStatusBar() self.SetStatusText("Not connected") self.Centre() self.Show() self.Bind(wx.EVT_CLOSE, self.on_close) # Bind global accelerators for search and quick escape accel_tbl = wx.AcceleratorTable([ (wx.ACCEL_CTRL, ord('F'), wx.ID_FIND), (wx.ACCEL_NORMAL, wx.WXK_F3, 1001), (wx.ACCEL_SHIFT, wx.WXK_F3, 1002), (wx.ACCEL_SHIFT, wx.WXK_ESCAPE, 1003), # Quick Escape ]) self.SetAcceleratorTable(accel_tbl) self.Bind(wx.EVT_MENU, self.on_global_search, id=wx.ID_FIND) self.Bind(wx.EVT_MENU, self.on_find_next, id=1001) self.Bind(wx.EVT_MENU, self.on_find_previous, id=1002) self.Bind(wx.EVT_MENU, self.on_quick_escape, id=1003) def apply_white_theme(self): """Apply white theme to the application""" try: # Set system colors for white theme self.SetBackgroundColour(wx.Colour(255, 255, 255)) self.SetForegroundColour(wx.Colour(0, 0, 0)) # Set system settings for light theme sys_settings = wx.SystemSettings() except Exception as e: logger.error(f"Error applying white theme: {e}") def show_privacy_notice(self): """Show privacy notice dialog at startup""" dlg = PrivacyNoticeDialog(self) dlg.ShowModal() dlg.Destroy() def get_user_color(self, username): """Get a consistent color for a username""" if username not in self.user_colors: self.user_colors[username] = self.available_colors[self.color_index] self.color_index = (self.color_index + 1) % len(self.available_colors) return self.user_colors[username] def on_global_search(self, event): """Handle global Ctrl+F search""" current_panel = self.get_current_panel() if current_panel: current_panel.on_search(event) def on_find_next(self, event): """Handle F3 for find next""" current_panel = self.get_current_panel() if current_panel and hasattr(current_panel, 'find_next'): current_panel.find_next() def on_find_previous(self, event): """Handle Shift+F3 for find previous""" current_panel = self.get_current_panel() if current_panel and hasattr(current_panel, 'find_previous'): current_panel.find_previous() def on_quick_escape(self, event): """Handle Shift+Esc for quick escape - exit immediately""" try: # Stop UI timer first if self.ui_timer and self.ui_timer.IsRunning(): self.ui_timer.Stop() # Force disconnect without sending quit message with self.connection_lock: if self.connection and self.connection.is_connected(): try: self.connection.close() except: pass # Exit immediately wx.CallAfter(self.Destroy) except Exception as e: logger.error(f"Error in quick escape: {e}") wx.CallAfter(self.Destroy) def get_current_panel(self): """Get the currently active IRC panel""" try: current_page = self.notebook.GetCurrentPage() if isinstance(current_page, IRCPanel): return current_page except: pass return None def setup_ui(self): """Setup UI components with white theme""" panel = wx.Panel(self) panel.SetBackgroundColour(wx.Colour(255, 255, 255)) # White background main_sizer = wx.BoxSizer(wx.HORIZONTAL) # Left sidebar - light gray for contrast left_panel = wx.Panel(panel) left_panel.SetBackgroundColour(wx.Colour(240, 240, 240)) # Light gray left_sizer = wx.BoxSizer(wx.VERTICAL) conn_box = wx.StaticBox(left_panel, label="Connection") conn_box.SetToolTip("Configure IRC server connection") conn_box_sizer = wx.StaticBoxSizer(conn_box, wx.VERTICAL) conn_box_sizer.Add(wx.StaticText(conn_box, label="Server:"), 0, wx.ALL, 2) self.server_ctrl = wx.TextCtrl(conn_box, value="irc.libera.chat") self.server_ctrl.SetToolTip("IRC server address") conn_box_sizer.Add(self.server_ctrl, 0, wx.EXPAND | wx.ALL, 2) conn_box_sizer.Add(wx.StaticText(conn_box, label="Port:"), 0, wx.ALL, 2) self.port_ctrl = wx.TextCtrl(conn_box, value="6667") self.port_ctrl.SetToolTip("IRC server port (usually 6667)") conn_box_sizer.Add(self.port_ctrl, 0, wx.EXPAND | wx.ALL, 2) conn_box_sizer.Add(wx.StaticText(conn_box, label="Nick:"), 0, wx.ALL, 2) self.nick_ctrl = wx.TextCtrl(conn_box, value="wxircuser") self.nick_ctrl.SetToolTip("Your nickname on IRC") conn_box_sizer.Add(self.nick_ctrl, 0, wx.EXPAND | wx.ALL, 2) self.connect_btn = wx.Button(conn_box, label="Connect") self.connect_btn.SetToolTip("Connect to IRC server") self.connect_btn.Bind(wx.EVT_BUTTON, self.on_connect) conn_box_sizer.Add(self.connect_btn, 0, wx.EXPAND | wx.ALL, 5) # Add Notes button to connection box # self.notes_btn = wx.Button(conn_box, label="Notes") # self.notes_btn.SetToolTip("Open notes editor") # self.notes_btn.Bind(wx.EVT_BUTTON, self.on_notes) # conn_box_sizer.Add(self.notes_btn, 0, wx.EXPAND | wx.ALL, 5) left_sizer.Add(conn_box_sizer, 0, wx.EXPAND | wx.ALL, 5) # Channel management chan_box = wx.StaticBox(left_panel, label="Channels") chan_box.SetToolTip("Join and manage channels") chan_box_sizer = wx.StaticBoxSizer(chan_box, wx.VERTICAL) join_sizer = wx.BoxSizer(wx.HORIZONTAL) self.channel_input = wx.TextCtrl(chan_box, style=wx.TE_PROCESS_ENTER) self.channel_input.SetHint("Enter channel name …") self.channel_input.SetToolTip("Type channel name …") self.channel_input.Bind(wx.EVT_TEXT_ENTER, self.on_join_channel) join_btn = wx.Button(chan_box, label="Join") join_btn.SetToolTip("Join channel") join_btn.Bind(wx.EVT_BUTTON, self.on_join_channel) join_sizer.Add(self.channel_input, 1, wx.EXPAND | wx.ALL, 2) join_sizer.Add(join_btn, 0, wx.ALL, 2) self.channel_list = wx.ListBox(chan_box) self.channel_list.SetToolTip("Joined channels") self.channel_list.Bind(wx.EVT_LISTBOX, self.on_channel_select) self.channel_list.Bind(wx.EVT_RIGHT_DOWN, self.on_channel_right_click) chan_box_sizer.Add(join_sizer, 0, wx.EXPAND | wx.ALL, 2) chan_box_sizer.Add(self.channel_list, 1, wx.EXPAND | wx.ALL, 2) left_sizer.Add(chan_box_sizer, 1, wx.EXPAND | wx.ALL, 5) left_panel.SetSizer(left_sizer) # Center - Notebook self.notebook = wx.Notebook(panel) self.notebook.SetBackgroundColour(wx.Colour(255, 255, 255)) # Server panel server_panel = IRCPanel(self.notebook, self) server_panel.set_target("SERVER") self.notebook.AddPage(server_panel, "Server") self.channels["SERVER"] = server_panel # Right sidebar - Users - light gray for contrast right_panel = wx.Panel(panel) right_panel.SetBackgroundColour(wx.Colour(240, 240, 240)) # Light gray right_sizer = wx.BoxSizer(wx.VERTICAL) users_box = wx.StaticBox(right_panel, label="Users") users_box_sizer = wx.StaticBoxSizer(users_box, wx.VERTICAL) self.users_list = wx.ListBox(users_box) self.users_list.Bind(wx.EVT_LISTBOX_DCLICK, self.on_user_dclick) self.users_list.Bind(wx.EVT_RIGHT_DOWN, self.on_user_right_click) users_box_sizer.Add(self.users_list, 1, wx.EXPAND | wx.ALL, 2) right_sizer.Add(users_box_sizer, 1, wx.EXPAND | wx.ALL, 5) right_panel.SetSizer(right_sizer) # Add to main sizer main_sizer.Add(left_panel, 0, wx.EXPAND | wx.ALL, 0) main_sizer.Add(self.notebook, 1, wx.EXPAND | wx.ALL, 0) main_sizer.Add(right_panel, 0, wx.EXPAND | wx.ALL, 0) left_panel.SetMinSize((220, -1)) right_panel.SetMinSize((180, -1)) panel.SetSizer(main_sizer) def process_ui_updates(self, event): """Process queued UI updates from timer event""" try: while True: try: update = self.ui_update_queue.get_nowait() if update and update.callback: update.callback(*update.args, **update.kwargs) except queue.Empty: break except Exception as e: logger.error(f"Error processing UI updates: {e}") def safe_ui_update(self, callback, *args, **kwargs): """Safely update UI from any thread""" try: if wx.IsMainThread(): callback(*args, **kwargs) else: self.ui_update_queue.put(UIUpdate(callback, *args, **kwargs)) except Exception as e: logger.error(f"Error in safe_ui_update: {e}") def create_menubar(self): try: menubar = wx.MenuBar() # File menu file_menu = wx.Menu() file_menu.Append(300, "&About", "About wxIRC Client") file_menu.AppendSeparator() file_menu.Append(wx.ID_EXIT, "E&xit\tCtrl+Q") self.Bind(wx.EVT_MENU, self.on_about, id=300) self.Bind(wx.EVT_MENU, self.on_close, id=wx.ID_EXIT) # Edit menu with search edit_menu = wx.Menu() edit_menu.Append(wx.ID_FIND, "&Find\tCtrl+F") self.Bind(wx.EVT_MENU, self.on_global_search, id=wx.ID_FIND) edit_menu.Append(1001, "Find &Next\tF3") edit_menu.Append(1002, "Find &Previous\tShift+F3") self.Bind(wx.EVT_MENU, self.on_find_next, id=1001) self.Bind(wx.EVT_MENU, self.on_find_previous, id=1002) # Channel menu channel_menu = wx.Menu() channel_menu.Append(101, "&Join Channel\tCtrl+J") channel_menu.Append(102, "&Part Channel\tCtrl+P") channel_menu.AppendSeparator() channel_menu.Append(103, "Close &Tab\tCtrl+W") self.Bind(wx.EVT_MENU, self.on_menu_join, id=101) self.Bind(wx.EVT_MENU, self.on_menu_part, id=102) self.Bind(wx.EVT_MENU, self.on_menu_close_tab, id=103) # Tools menu tools_menu = wx.Menu() tools_menu.Append(210, "&wxScan\tCtrl+S") tools_menu.Append(208, "&wxNotes\tCtrl+T") # Add Notes menu item tools_menu.Append(201, "&WHOIS User\tCtrl+I") tools_menu.Append(202, "Change &Nick\tCtrl+N") tools_menu.AppendSeparator() self.away_item = tools_menu.AppendCheckItem(203, "&Away\tCtrl+A") self.timestamp_item = tools_menu.AppendCheckItem(204, "Show &Timestamps") self.timestamp_item.Check(True) tools_menu.AppendSeparator() tools_menu.Append(205, "Set &Highlights") tools_menu.Append(206, "Auto-join Channels") tools_menu.Append(207, "Command Help") self.Bind(wx.EVT_MENU, self.on_scan_local_network, id=210) self.Bind(wx.EVT_MENU, self.on_notes, id=208) # Bind Notes menu item self.Bind(wx.EVT_MENU, self.on_menu_whois, id=201) self.Bind(wx.EVT_MENU, self.on_menu_change_nick, id=202) self.Bind(wx.EVT_MENU, self.on_menu_away, id=203) self.Bind(wx.EVT_MENU, self.on_menu_timestamps, id=204) self.Bind(wx.EVT_MENU, self.on_menu_highlights, id=205) self.Bind(wx.EVT_MENU, self.on_menu_autojoin, id=206) self.Bind(wx.EVT_MENU, self.on_menu_help, id=207) self.Bind(wx.EVT_MENU, self.on_scan_local_network, id=210) menubar.Append(file_menu, "&File") menubar.Append(edit_menu, "&Edit") menubar.Append(channel_menu, "&Channel") menubar.Append(tools_menu, "&Tools") self.SetMenuBar(menubar) except Exception as e: logger.error(f"Error creating menu: {e}") def on_about(self, event): """Show About dialog""" try: dlg = AboutDialog(self) dlg.ShowModal() dlg.Destroy() except Exception as e: logger.error(f"Error showing about dialog: {e}") def on_notes(self, event): """Open notes editor dialog""" try: # Check if notes window already exists if hasattr(self, 'notes_frame') and self.notes_frame: try: self.notes_frame.Raise() # Bring to front if already open return except: # Frame was destroyed, create new one pass self.notes_frame = NotesDialog(self, self.notes_data) self.notes_frame.Bind(wx.EVT_CLOSE, self.on_notes_closed) self.notes_frame.Show() except Exception as e: logger.error(f"Error opening notes dialog: {e}") self.log_server(f"Error opening notes: {e}", wx.Colour(255, 0, 0)) def on_notes_closed(self, event=None): """Handle notes frame closing""" if hasattr(self, 'notes_frame') and self.notes_frame: try: # Update notes data from the frame before it closes self.notes_data = self.notes_frame.notes_data except Exception as e: logger.error(f"Error getting notes data on close: {e}") finally: self.notes_frame = None if event: event.Skip() # Allow the event to propagate def setup_irc_handlers(self): try: self.reactor = irc.client.Reactor() self.reactor.add_global_handler("all_events", self.on_all_events) # Catch all events self.reactor.add_global_handler("welcome", self.on_welcome) self.reactor.add_global_handler("join", self.on_join) self.reactor.add_global_handler("part", self.on_part) self.reactor.add_global_handler("quit", self.on_quit) self.reactor.add_global_handler("pubmsg", self.on_pubmsg) self.reactor.add_global_handler("privmsg", self.on_privmsg) self.reactor.add_global_handler("namreply", self.on_namreply) self.reactor.add_global_handler("nick", self.on_nick) self.reactor.add_global_handler("mode", self.on_mode) self.reactor.add_global_handler("notice", self.on_notice) self.reactor.add_global_handler("disconnect", self.on_disconnect) self.reactor.add_global_handler("topic", self.on_topic) self.reactor.add_global_handler("kick", self.on_kick) self.reactor.add_global_handler("whoisuser", self.on_whoisuser) self.reactor.add_global_handler("whoischannels", self.on_whoischannels) self.reactor.add_global_handler("whoisserver", self.on_whoisserver) self.reactor.add_global_handler("375", self.on_motd_start) self.reactor.add_global_handler("372", self.on_motd_line) self.reactor.add_global_handler("376", self.on_motd_end) except Exception as e: logger.error(f"Error setting up IRC handlers: {e}") def on_all_events(self, connection, event): """Catch-all handler to log ALL server events in the Server tab""" try: # Don't log certain very frequent events to avoid spam noisy_events = {"pubmsg", "privmsg", "action", "motd", "motdstart", "motdend", "375", "372", "376"} event_type = event.type.lower() if isinstance(event.type, str) else event.type if event_type in noisy_events: return # Format the raw event for display event_info = f"RAW: {event.type.upper()}" if hasattr(event, 'source') and event.source: event_info += f" from {event.source}" if hasattr(event, 'target') and event.target: event_info += f" to {event.target}" if hasattr(event, 'arguments') and event.arguments: event_info += f" - {' '.join(event.arguments)}" self.log_server(event_info, wx.Colour(0, 0, 128), italic=True) # Dark blue for raw events except Exception as e: logger.error(f"Error in all_events handler: {e}") def get_timestamp(self): try: if self.timestamps: return f"[{datetime.now().strftime('%H:%M:%S')}] " return "" except Exception as e: logger.error(f"Error getting timestamp: {e}") return "" def is_connected(self): """Thread-safe connection check""" with self.connection_lock: return self.connection and self.connection.is_connected() def on_connect(self, event): try: if not self.is_connected() and not self.is_connecting: self.is_connecting = True self.server = self.server_ctrl.GetValue() try: self.port = int(self.port_ctrl.GetValue()) except ValueError: self.safe_ui_update(self.log_server, "Invalid port number", wx.Colour(255, 0, 0)) self.is_connecting = False return self.nick = self.nick_ctrl.GetValue() if not self.server or not self.nick: self.safe_ui_update(self.log_server, "Server and nick are required", wx.Colour(255, 0, 0)) self.is_connecting = False return self.safe_ui_update(self.connect_btn.Enable, False) self.safe_ui_update(self.log_server, f"Connecting to {self.server}:{self.port} as {self.nick}...", wx.Colour(0, 0, 128)) def connect_thread(): try: logger.info(f"Attempting connection to {self.server}:{self.port}") self.connection = self.reactor.server().connect( self.server, self.port, self.nick, username=self.nick, ircname=self.nick, connect_factory=irc.connection.Factory() ) self.reactor_thread = threading.Thread( target=self.safe_reactor_loop, name="IRC-Reactor", daemon=True ) self.reactor_thread.start() self.safe_ui_update(self.on_connect_success) except irc.client.ServerConnectionError as e: error_msg = f"Connection error: {e}" logger.error(error_msg) self.safe_ui_update(self.on_connect_failed, error_msg) except socket.gaierror as e: error_msg = f"DNS resolution failed: {e}" logger.error(error_msg) self.safe_ui_update(self.on_connect_failed, error_msg) except Exception as e: error_msg = f"Unexpected connection error: {e}" logger.error(error_msg) self.safe_ui_update(self.on_connect_failed, error_msg) threading.Thread(target=connect_thread, name="IRC-Connect", daemon=True).start() elif self.is_connected(): self.disconnect() except Exception as e: logger.error(f"Error in connect handler: {e}") self.is_connecting = False self.safe_ui_update(self.connect_btn.Enable, True) def safe_reactor_loop(self): """Safely run the reactor loop with exception handling""" try: self.reactor.process_forever() except Exception as e: logger.error(f"Reactor loop error: {e}") self.safe_ui_update(self.log_server, f"Connection error: {e}", wx.Colour(255, 0, 0)) self.safe_ui_update(self.on_disconnect_cleanup) def on_connect_success(self): """Handle successful connection""" self.is_connecting = False self.connect_btn.SetLabel("Disconnect") self.connect_btn.Enable(True) self.SetStatusText(f"Connected to {self.server} - Use /help for commands, Ctrl+F to search, Shift+Esc to quick exit") logger.info(f"Successfully connected to {self.server}") def on_connect_failed(self, error_msg): """Handle connection failure""" self.is_connecting = False self.log_server(error_msg, wx.Colour(255, 0, 0)) self.connect_btn.Enable(True) self.SetStatusText("Connection failed") logger.error(f"Connection failed: {error_msg}") def disconnect(self): """Safely disconnect from IRC server""" try: with self.connection_lock: if self.connection and self.connection.is_connected(): self.is_disconnecting = True self.connection.quit("Goodbye") # Give it a moment to send the quit message threading.Timer(1.0, self.force_disconnect).start() else: self.on_disconnect_cleanup() except Exception as e: logger.error(f"Error during disconnect: {e}") self.on_disconnect_cleanup() def force_disconnect(self): """Force disconnect if graceful quit fails""" try: with self.connection_lock: if self.connection: self.connection.close() self.on_disconnect_cleanup() except Exception as e: logger.error(f"Error during force disconnect: {e}") self.on_disconnect_cleanup() def on_disconnect_cleanup(self): """Clean up after disconnect""" with self.connection_lock: self.connection = None self.is_connecting = False self.is_disconnecting = False self.safe_ui_update(self.connect_btn.SetLabel, "Connect") self.safe_ui_update(self.connect_btn.Enable, True) self.safe_ui_update(self.SetStatusText, "Disconnected") self.safe_ui_update(self.log_server, "Disconnected from server", wx.Colour(255, 0, 0)) def on_join_channel(self, event): try: channel = self.channel_input.GetValue().strip() if channel and self.is_connected(): if not channel.startswith('#'): channel = '#' + channel self.connection.join(channel) self.channel_input.Clear() except Exception as e: logger.error(f"Error joining channel: {e}") self.safe_ui_update(self.log_server, f"Error joining channel: {e}", wx.Colour(255, 0, 0)) def on_channel_select(self, event): try: selection = self.channel_list.GetSelection() if selection != wx.NOT_FOUND: channel = self.channel_list.GetString(selection) self.switch_to_channel(channel) except Exception as e: logger.error(f"Error selecting channel: {e}") def on_channel_right_click(self, event): try: selection = self.channel_list.HitTest(event.GetPosition()) if selection != wx.NOT_FOUND: channel = self.channel_list.GetString(selection) menu = wx.Menu() menu.Append(1, "Part Channel") menu.Append(2, "Close Tab") self.Bind(wx.EVT_MENU, lambda e: self.part_channel(channel), id=1) self.Bind(wx.EVT_MENU, lambda e: self.close_channel(channel), id=2) self.PopupMenu(menu) menu.Destroy() except Exception as e: logger.error(f"Error in channel right click: {e}") def on_user_dclick(self, event): try: selection = self.users_list.GetSelection() if selection != wx.NOT_FOUND: user = self.users_list.GetString(selection) self.open_query(user) except Exception as e: logger.error(f"Error in user double click: {e}") def on_user_right_click(self, event): try: selection = self.users_list.HitTest(event.GetPosition()) if selection != wx.NOT_FOUND: user = self.users_list.GetString(selection) menu = wx.Menu() menu.Append(1, f"Query {user}") menu.Append(2, f"WHOIS {user}") if self.current_channel and self.current_channel.startswith('#'): menu.AppendSeparator() menu.Append(3, f"Kick {user}") self.Bind(wx.EVT_MENU, lambda e: self.open_query(user), id=1) self.Bind(wx.EVT_MENU, lambda e: self.whois_user(user), id=2) if self.current_channel and self.current_channel.startswith('#'): self.Bind(wx.EVT_MENU, lambda e: self.kick_user(user), id=3) self.PopupMenu(menu) menu.Destroy() except Exception as e: logger.error(f"Error in user right click: {e}") def open_query(self, user): try: if user not in self.channels: self.add_channel(user) self.switch_to_channel(user) except Exception as e: logger.error(f"Error opening query: {e}") def whois_user(self, user): try: if self.is_connected(): self.connection.whois([user]) except Exception as e: logger.error(f"Error in WHOIS: {e}") def kick_user(self, user): try: if self.current_channel and self.current_channel.startswith('#') and self.is_connected(): reason = wx.GetTextFromUser("Kick reason:", "Kick User", "Kicked") if reason is not None: self.connection.kick(self.current_channel, user, reason) except Exception as e: logger.error(f"Error kicking user: {e}") def switch_to_channel(self, channel): try: self.current_channel = channel for i in range(self.notebook.GetPageCount()): if self.notebook.GetPageText(i) == channel: self.notebook.SetSelection(i) break self.update_user_list(channel) except Exception as e: logger.error(f"Error switching channel: {e}") def update_user_list(self, channel): """Thread-safe user list update""" def _update_user_list(): try: self.users_list.Clear() if channel in self.channel_users: for user in sorted(self.channel_users[channel]): self.users_list.Append(user) except Exception as e: logger.error(f"Error updating user list: {e}") self.safe_ui_update(_update_user_list) def send_message(self, target, message): try: if message.startswith('/'): self.handle_command(target, message) elif target != "SERVER" and self.is_connected(): self.connection.privmsg(target, message) if target in self.channels: # Show our own message with our color user_color = self.get_user_color(self.nick) timestamp = self.get_timestamp() self.channels[target].add_formatted_message(timestamp, self.nick, message, user_color) except Exception as e: logger.error(f"Error sending message: {e}") self.safe_ui_update(self.log_server, f"Error sending message: {e}", wx.Colour(255, 0, 0)) def handle_command(self, target, message): try: parts = message[1:].split(' ', 1) cmd = parts[0].lower() args = parts[1] if len(parts) > 1 else "" if cmd == "help": help_text = """ Available commands: /join - Join a channel /part [channel] - Leave current or specified channel /msg - Send private message /me - Send action message /nick - Change nickname /whois - Get user information /topic [newtopic] - Get or set channel topic /kick [reason] - Kick user from channel /away [message] - Set away status /quit [message] - Disconnect from server /help - Show this help """ self.log_server(help_text, wx.Colour(0, 100, 0)) # Dark green for help elif cmd == "me": if self.is_connected(): self.connection.action(target, args) user_color = self.get_user_color(self.nick) timestamp = self.get_timestamp() self.channels[target].add_formatted_message(timestamp, self.nick, args, user_color, is_action=True) elif cmd == "nick" and self.is_connected(): self.connection.nick(args) elif cmd == "join" and self.is_connected(): if args and not args.startswith('#'): args = '#' + args self.connection.join(args) elif cmd == "part" and self.is_connected(): channel = args if args else target self.connection.part(channel) elif cmd == "quit" and self.is_connected(): reason = args if args else "Goodbye" self.connection.quit(reason) elif cmd == "msg" and self.is_connected(): nick_msg = args.split(' ', 1) if len(nick_msg) == 2: self.connection.privmsg(nick_msg[0], nick_msg[1]) elif cmd == "whois" and self.is_connected(): self.connection.whois([args]) elif cmd == "kick" and self.is_connected(): kick_args = args.split(' ', 1) user = kick_args[0] reason = kick_args[1] if len(kick_args) > 1 else "Kicked" self.connection.kick(target, user, reason) elif cmd == "topic" and self.is_connected(): if args: self.connection.topic(target, args) else: self.connection.topic(target) elif cmd == "away" and self.is_connected(): self.connection.send_raw(f"AWAY :{args}" if args else "AWAY") self.away = bool(args) self.safe_ui_update(self.away_item.Check, self.away) else: self.safe_ui_update(self.log_server, f"Unknown command: {cmd}. Use /help for available commands.", wx.Colour(255, 0, 0)) except Exception as e: logger.error(f"Error handling command: {e}") self.safe_ui_update(self.log_server, f"Error executing command: {e}", wx.Colour(255, 0, 0)) def part_channel(self, channel): try: if self.is_connected(): self.connection.part(channel) except Exception as e: logger.error(f"Error parting channel: {e}") def close_channel(self, channel): try: if channel in self.channels and channel != "SERVER": def _close_channel(): for i in range(self.notebook.GetPageCount()): if self.notebook.GetPageText(i) == channel: self.notebook.DeletePage(i) break del self.channels[channel] idx = self.channel_list.FindString(channel) if idx != wx.NOT_FOUND: self.channel_list.Delete(idx) self.safe_ui_update(_close_channel) except Exception as e: logger.error(f"Error closing channel: {e}") def log_server(self, message, color=None, bold=False, italic=False, underline=False): try: if "SERVER" in self.channels: self.channels["SERVER"].add_system_message(f"{self.get_timestamp()}{message}", color, bold) except Exception as e: logger.error(f"Error logging server message: {e}") def log_channel_message(self, channel, username, message, is_action=False, is_system=False): """Log a message to a channel with username coloring""" try: if channel not in self.channels: self.safe_ui_update(self.add_channel, channel) if channel in self.channels: timestamp = self.get_timestamp() if is_system: # System messages (joins, parts, etc.) user_color = self.get_user_color(username) self.channels[channel].add_system_message(f"{timestamp}{message}", user_color) elif is_action: # Action messages (/me) user_color = self.get_user_color(username) self.channels[channel].add_formatted_message(timestamp, username, message, user_color, is_action=True) else: # Regular messages user_color = self.get_user_color(username) self.channels[channel].add_formatted_message(timestamp, username, message, user_color) # Check for highlights if self.highlights and any(h.lower() in message.lower() for h in self.highlights): self.safe_ui_update(wx.Bell) except Exception as e: logger.error(f"Error logging channel message: {e}") def add_channel(self, channel): try: if channel not in self.channels: panel = IRCPanel(self.notebook, self) panel.set_target(channel) self.notebook.AddPage(panel, channel) self.channels[channel] = panel if channel.startswith('#'): self.channel_list.Append(channel) except Exception as e: logger.error(f"Error adding channel: {e}") def on_scan_local_network(self, event): """Launch the local network scan wizard.""" try: wizard = ScanWizardDialog(self) wizard.run() wizard.Destroy() except Exception as e: logger.error(f"Error opening scan wizard: {e}") self.log_server(f"Scan wizard failed to open: {e}", wx.Colour(255, 0, 0)) def quick_connect(self, server, port): """Populate connection fields and initiate a connection if idle.""" try: if self.is_connected() or self.is_connecting: wx.MessageBox("Please disconnect before using Quick Connect.", "Already connected", wx.OK | wx.ICON_INFORMATION) return False self.server_ctrl.SetValue(server) self.port_ctrl.SetValue(str(port)) self.SetStatusText(f"Quick connect ready: {server}:{port}") wx.CallAfter(self.on_connect, None) self.log_server(f"Quick connecting to {server}:{port}", wx.Colour(0, 0, 128)) return True except Exception as e: logger.error(f"Quick connect failed: {e}") self.log_server(f"Quick connect failed: {e}", wx.Colour(255, 0, 0)) return False # Menu handlers def on_menu_join(self, event): try: dlg = wx.TextEntryDialog(self, "Enter channel name:", "Join Channel") if dlg.ShowModal() == wx.ID_OK: channel = dlg.GetValue() if channel and self.is_connected(): if not channel.startswith('#'): channel = '#' + channel self.connection.join(channel) dlg.Destroy() except Exception as e: logger.error(f"Error in menu join: {e}") def on_menu_part(self, event): try: if self.current_channel and self.current_channel != "SERVER": self.part_channel(self.current_channel) except Exception as e: logger.error(f"Error in menu part: {e}") def on_menu_close_tab(self, event): try: if self.current_channel and self.current_channel != "SERVER": self.close_channel(self.current_channel) except Exception as e: logger.error(f"Error in menu close tab: {e}") def on_menu_whois(self, event): try: dlg = wx.TextEntryDialog(self, "Enter nickname:", "WHOIS") if dlg.ShowModal() == wx.ID_OK: user = dlg.GetValue() if user and self.is_connected(): self.whois_user(user) dlg.Destroy() except Exception as e: logger.error(f"Error in menu whois: {e}") def on_menu_change_nick(self, event): try: dlg = wx.TextEntryDialog(self, "Enter new nickname:", "Change Nick", self.nick) if dlg.ShowModal() == wx.ID_OK: new_nick = dlg.GetValue() if new_nick and self.is_connected(): self.connection.nick(new_nick) dlg.Destroy() except Exception as e: logger.error(f"Error in menu change nick: {e}") def on_menu_away(self, event): try: if self.away and self.is_connected(): self.connection.send_raw("AWAY") self.away = False self.SetStatusText(f"Connected to {self.server}") elif self.is_connected(): msg = wx.GetTextFromUser("Away message:", "Set Away", "Away from keyboard") if msg is not None: self.connection.send_raw(f"AWAY :{msg}") self.away = True self.SetStatusText(f"Connected to {self.server} (Away)") except Exception as e: logger.error(f"Error in menu away: {e}") def on_menu_timestamps(self, event): try: self.timestamps = self.timestamp_item.IsChecked() except Exception as e: logger.error(f"Error in menu timestamps: {e}") def on_menu_highlights(self, event): try: current = ", ".join(self.highlights) dlg = wx.TextEntryDialog(self, "Enter highlight words (comma separated):", "Set Highlights", current) if dlg.ShowModal() == wx.ID_OK: text = dlg.GetValue() self.highlights = [h.strip() for h in text.split(',') if h.strip()] dlg.Destroy() except Exception as e: logger.error(f"Error in menu highlights: {e}") def on_menu_autojoin(self, event): try: current = ", ".join(self.auto_join_channels) dlg = wx.TextEntryDialog(self, "Enter channels to auto-join (comma separated):", "Auto-join Channels", current) if dlg.ShowModal() == wx.ID_OK: text = dlg.GetValue() self.auto_join_channels = [c.strip() for c in text.split(',') if c.strip()] dlg.Destroy() except Exception as e: logger.error(f"Error in menu autojoin: {e}") def on_menu_help(self, event): """Show command help""" help_text = """ IRC Client Help: BASIC USAGE: - Configure server/nick in left panel and click Connect - Join channels using the join box or /join command - Type messages in the input box at bottom - Use Up/Down arrows for message history - Tab for nickname completion in channels - Ctrl+F to search in chat history - Shift+Esc to quickly exit the application TEXT FORMATTING: - Usernames are colored for easy identification - URLs are automatically clickable - Each user has a consistent color COMMANDS (type /help in chat for full list): /join #channel - Join a channel /part - Leave current channel /msg nick message - Private message /me action - Action message /nick newname - Change nickname /away [message] - Set away status """ self.log_server(help_text, wx.Colour(0, 100, 0), bold=True) # Dark green for help # IRC Event Handlers - All use thread-safe UI updates def on_welcome(self, connection, event): try: self.log_server("Connected to server!", wx.Colour(0, 128, 0), bold=True) # Dark green self.log_server(f"Welcome message: {' '.join(event.arguments)}", wx.Colour(0, 100, 0)) # Auto-join channels for channel in self.auto_join_channels: if not channel.startswith('#'): channel = '#' + channel time.sleep(0.5) if self.is_connected(): connection.join(channel) except Exception as e: logger.error(f"Error in welcome handler: {e}") def on_join(self, connection, event): try: nick = event.source.nick channel = event.target if nick == self.nick: self.safe_ui_update(self.add_channel, channel) self.log_server(f"Joined channel {channel}", wx.Colour(0, 128, 0)) # Dark green self.log_channel_message(channel, nick, f"→ {nick} joined", is_system=True) if nick not in self.channel_users[channel]: self.channel_users[channel].append(nick) if channel == self.current_channel: self.update_user_list(channel) except Exception as e: logger.error(f"Error in join handler: {e}") def on_part(self, connection, event): try: nick = event.source.nick channel = event.target reason = event.arguments[0] if event.arguments else "" msg = f"← {nick} left" if reason: msg += f" ({reason})" self.log_channel_message(channel, nick, msg, is_system=True) if nick in self.channel_users[channel]: self.channel_users[channel].remove(nick) if channel == self.current_channel: self.update_user_list(channel) except Exception as e: logger.error(f"Error in part handler: {e}") def on_quit(self, connection, event): try: nick = event.source.nick reason = event.arguments[0] if event.arguments else "Quit" for channel in list(self.channel_users.keys()): if nick in self.channel_users[channel]: self.channel_users[channel].remove(nick) self.log_channel_message(channel, nick, f"← {nick} quit ({reason})", is_system=True) if self.current_channel: self.update_user_list(self.current_channel) except Exception as e: logger.error(f"Error in quit handler: {e}") def on_pubmsg(self, connection, event): try: nick = event.source.nick channel = event.target message = event.arguments[0] # Check for action messages (/me) if message.startswith('\x01ACTION') and message.endswith('\x01'): message = message[8:-1] self.log_channel_message(channel, nick, message, is_action=True) else: self.log_channel_message(channel, nick, message) # Highlight own nick in messages if self.nick.lower() in message.lower(): self.safe_ui_update(wx.Bell) except Exception as e: logger.error(f"Error in pubmsg handler: {e}") def on_privmsg(self, connection, event): try: nick = event.source.nick message = event.arguments[0] # Check for action messages in private queries too if message.startswith('\x01ACTION') and message.endswith('\x01'): message = message[8:-1] self.log_channel_message(nick, nick, message, is_action=True) else: self.log_channel_message(nick, nick, message) except Exception as e: logger.error(f"Error in privmsg handler: {e}") def on_namreply(self, connection, event): try: channel = event.arguments[1] users = event.arguments[2].split() clean_users = [] for user in users: user = user.lstrip("@+%&~") clean_users.append(user) self.channel_users[channel].extend(clean_users) if channel == self.current_channel: self.update_user_list(channel) except Exception as e: logger.error(f"Error in namreply handler: {e}") def on_nick(self, connection, event): try: old_nick = event.source.nick new_nick = event.target # Update color mapping for the new nick if old_nick in self.user_colors: self.user_colors[new_nick] = self.user_colors[old_nick] del self.user_colors[old_nick] if old_nick == self.nick: self.nick = new_nick self.log_server(f"You are now known as {new_nick}", wx.Colour(0, 0, 128), bold=True) # Dark blue for channel in self.channel_users: if old_nick in self.channel_users[channel]: self.channel_users[channel].remove(old_nick) self.channel_users[channel].append(new_nick) self.log_channel_message(channel, new_nick, f"{old_nick} is now known as {new_nick}", is_system=True) if self.current_channel: self.update_user_list(self.current_channel) except Exception as e: logger.error(f"Error in nick handler: {e}") def on_mode(self, connection, event): try: channel = event.target mode = " ".join(event.arguments) nick = event.source.nick self.log_channel_message(channel, nick, f"Mode {mode} by {nick}", is_system=True) except Exception as e: logger.error(f"Error in mode handler: {e}") def on_notice(self, connection, event): try: nick = event.source.nick if hasattr(event.source, 'nick') else str(event.source) message = event.arguments[0] self.log_server(f"-{nick}- {message}", wx.Colour(128, 0, 128), italic=True) # Dark purple for notices except Exception as e: logger.error(f"Error in notice handler: {e}") def on_motd_start(self, connection, event): """Handle numeric 375 — start of MOTD.""" try: self.collecting_motd = True self.motd_lines = [] headline = event.arguments[-1] if event.arguments else "Message of the Day" self.log_server(f"MOTD begins: {headline}", wx.Colour(0, 0, 128), bold=True) except Exception as e: logger.error(f"Error in MOTD start handler: {e}") def on_motd_line(self, connection, event): """Handle numeric 372 — each MOTD line.""" try: if not self.collecting_motd: self.collecting_motd = True self.motd_lines = [] if event.arguments: raw_line = event.arguments[-1] cleaned = raw_line.lstrip("- ").rstrip() if cleaned: self.motd_lines.append(cleaned) except Exception as e: logger.error(f"Error in MOTD line handler: {e}") def on_motd_end(self, connection, event): """Handle numeric 376 — end of MOTD.""" try: if self.motd_lines: panel = self.channels.get("SERVER") if panel: for line in self.motd_lines: panel.add_system_message(f" {line}", wx.Colour(70, 70, 70)) self.log_server("End of MOTD", wx.Colour(0, 0, 128)) self.collecting_motd = False self.motd_lines = [] except Exception as e: logger.error(f"Error in MOTD end handler: {e}") def on_disconnect(self, connection, event): try: self.log_server("Disconnected from server", wx.Colour(255, 0, 0), bold=True) # Red for disconnect self.safe_ui_update(self.on_disconnect_cleanup) except Exception as e: logger.error(f"Error in disconnect handler: {e}") def on_topic(self, connection, event): try: channel = event.arguments[0] if event.arguments else event.target topic = event.arguments[1] if len(event.arguments) > 1 else event.arguments[0] nick = event.source.nick if hasattr(event.source, 'nick') else "Server" self.log_channel_message(channel, nick, f"Topic: {topic}", is_system=True) except Exception as e: logger.error(f"Error in topic handler: {e}") def on_kick(self, connection, event): try: channel = event.target kicked = event.arguments[0] reason = event.arguments[1] if len(event.arguments) > 1 else "No reason" kicker = event.source.nick self.log_channel_message(channel, kicker, f"{kicked} was kicked by {kicker} ({reason})", is_system=True) if kicked in self.channel_users[channel]: self.channel_users[channel].remove(kicked) if channel == self.current_channel: self.update_user_list(channel) except Exception as e: logger.error(f"Error in kick handler: {e}") def on_whoisuser(self, connection, event): try: nick = event.arguments[0] user = event.arguments[1] host = event.arguments[2] realname = event.arguments[4] user_color = self.get_user_color(nick) self.log_server(f"WHOIS {nick}: {user}@{host} ({realname})", user_color) except Exception as e: logger.error(f"Error in whoisuser handler: {e}") def on_whoischannels(self, connection, event): try: nick = event.arguments[0] channels = event.arguments[1] user_color = self.get_user_color(nick) self.log_server(f"WHOIS {nick} channels: {channels}", user_color) except Exception as e: logger.error(f"Error in whoischannels handler: {e}") def on_whoisserver(self, connection, event): try: nick = event.arguments[0] server = event.arguments[1] user_color = self.get_user_color(nick) self.log_server(f"WHOIS {nick} server: {server}", user_color) except Exception as e: logger.error(f"Error in whoisserver handler: {e}") def on_close(self, event): try: # Stop UI timer first if self.ui_timer and self.ui_timer.IsRunning(): self.ui_timer.Stop() # Notes data will be lost when app closes (RAM only) # User can save to file if they want persistence if self.is_connected(): self.disconnect() # Give it a moment to disconnect gracefully wx.CallLater(1000, self.Destroy) else: self.Destroy() except Exception as e: logger.error(f"Error during close: {e}") self.Destroy() if os.name == 'nt': import ctypes def enable_high_dpi(): try: ctypes.windll.shcore.SetProcessDpiAwareness(1) except: try: ctypes.windll.user32.SetProcessDPIAware() except: pass else: pass if __name__ == "__main__": try: if os.name == 'nt': enable_high_dpi() app = wx.App() frame = IRCFrame() frame.SetIcon(wx.Icon(get_resource_path("icon.ico"), wx.BITMAP_TYPE_ICO)) logger.info(f"wxID: {frame.GetId()}") logger.info(f"HWND: {hex(frame.GetHandle())}") app.MainLoop() except Exception as e: logger.critical(f"Fatal error: {e}") print(f"Fatal error: {e}") traceback.print_exc()