1361 lines
56 KiB
Python
1361 lines
56 KiB
Python
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 <channel> - Join a channel
|
|
/part [channel] - Leave current or specified channel
|
|
/msg <nick> <message> - Send private message
|
|
/me <action> - Send action message
|
|
/nick <newnick> - Change nickname
|
|
/whois <nick> - Get user information
|
|
/topic [newtopic] - Get or set channel topic
|
|
/kick <user> [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() |