diff --git a/src/app/chat_client.py b/src/app/chat_client.py index 8c9b5dd..401f801 100644 --- a/src/app/chat_client.py +++ b/src/app/chat_client.py @@ -1,7 +1,7 @@ import socket, threading, re from textual.app import App, ComposeResult from textual.containers import Horizontal -from textual.widgets import Header, Footer, Input, Static +from textual.widgets import Header, Footer, Input, Log, Static from textual.reactive import reactive from rich.text import Text @@ -25,27 +25,36 @@ class ChatClient(App): width: 24; height: 1fr; overflow: auto; } #input_widget { border: round blue; height: 3; } + #channel_display { + border: round yellow; + height: 3; + text-align: center; + padding-top: 1; + } """ chat_log: reactive[list[Text]] = reactive([]) - users: reactive[list[str]] = reactive([]) - username: str | None = None + users: reactive[list[str]] = reactive([]) + username: str | None = None + channel: reactive[str] = reactive("Connecting...") def __init__(self): super().__init__() self.stop_event = threading.Event() self.sock: socket.socket | None = None - self.user_colors: dict[str,str] = {} - self.palette = ["cyan","green","yellow","blue","magenta","red","white"] + self.user_colors: dict[str, str] = {} + self.palette = ["cyan", "green", "yellow", "blue", "magenta", "red", "white"] def compose(self) -> ComposeResult: yield Header(show_clock=True) + self.channel_display = Static(id="channel_display") + yield self.channel_display with Horizontal(): self.chat_display = Static(id="chat_display") self.users_display = Static(id="users_display") yield self.chat_display yield self.users_display - self.input_box = Input(placeholder="Type /help for commands", id="input_widget") + self.input_box = Input(placeholder=f"Cmds: /help", id="input_widget") yield self.input_box yield Footer() @@ -81,18 +90,34 @@ def _recv_loop(self): except: msg = Text(text) self.chat_log.append(msg) + # User list update + match = re.search(r"Users: \[(.*?)\]", text) + if match: + user_list_str = match.group(1) + # The user list can be tricky to parse because of the quotes. + # Let's use a more robust way to parse it. + self.users = [u.strip().strip("'") for u in user_list_str.split(",")] + + # update users list on join/leave - if "Joined the server" in text or "Left the server" in text: + if "Joined the channel" in text or "Left the channel" in text: names = re.findall(r"\[magenta\](.*?)\[/magenta\]", text) for n in names: if "Joined" in text and n not in self.users: self.users.append(n) - if "Left" in text and n in self.users: + if "Left" in text and n in self.users: self.users.remove(n) + + # channel join/leave status + match = re.search(r"You joined (.*?)\. Users:", text) + if match: + self.channel = match.group(1) + + # Welcome - m = re.match(r"Server: Welcome (.+)", text) + m = re.match(r"Server: Welcome (.*?), you have joined (#\w+)", text) if m and not self.username: - self.username = m.group(1) + self.username, self.channel = m.groups() self.users.append(self.username) else: # normal user message @@ -139,12 +164,15 @@ async def on_input_submitted(self, event: Input.Submitted): self.exit() def update_displays(self): + # channel + self.channel_display.update(Text(self.channel, justify="center", style="bold yellow")) # chat t = Text() for line in self.chat_log[-200:]: t.append(line) t.append("\n") self.chat_display.update(t) + self.call_later(self.chat_display.scroll_end, animate=False) # users self.users_display.update(Text("\n".join(self.users), style="bold magenta")) diff --git a/src/app/constants.py b/src/app/constants.py index 3d4f7f6..3005d1e 100644 --- a/src/app/constants.py +++ b/src/app/constants.py @@ -1,6 +1,15 @@ MAX_CLIENTS_SIZE = 64 - - - -MESSAGE_CLOSE = "/close" \ No newline at end of file +MESSAGE_CLOSE = "/close" + +DEFAULT_CHANNEL = "#general" + +COMMANDS = [ + "/close", + "/nick", + "/users", + "/help", + "/join", + "/leave", + "/create", +] \ No newline at end of file diff --git a/src/app/server.py b/src/app/server.py index ca41f2d..aeb5f10 100644 --- a/src/app/server.py +++ b/src/app/server.py @@ -6,18 +6,38 @@ HOST = "localhost" PORT = 6667 -threads = {} # client_socket -> thread -clients = {} # client_socket -> [username, addr] +lock = threading.RLock() +threads = {} # client_socket -> thread +clients = {} # client_socket -> [username, addr, channel] usernames = ["Server"] +channels = {DEFAULT_CHANNEL: []} # channel_name -> [sockets] -def broadcast(sender_sock, msg: str): - """Send msg to everyone except sender.""" - for sock in list(clients): + +def broadcast(sender_sock, msg: str, channel: str): + """Send msg to everyone in the channel except sender.""" + with lock: + if channel not in channels: + return + # Make a copy of the list of sockets to iterate over + sockets_to_send = list(channels[channel]) + + for sock in sockets_to_send: if sock is not sender_sock: try: sock.sendall(msg.encode("utf-8")) except: - pass + # remove dead socket + with lock: + if sock in clients: + uname, _, channel = clients[sock] + usernames.remove(uname) + if channel in channels and sock in channels[channel]: + channels[channel].remove(sock) + del clients[sock] + try: + sock.close() + except: + pass def send_to(sock, msg: str): """Send msg only to this sock.""" @@ -27,67 +47,121 @@ def send_to(sock, msg: str): pass def handle_commands(cmd: str, sock: socket.socket): - uname, _ = clients[sock] - parts = cmd.split() - if parts[0] == "/help": - help_text = ( - "Server: Available commands:\n" - "/help Show this message\n" - "/users Number of connected users\n" - "/nick Change your nickname\n" - "/close Leave the chat\n" - ) - send_to(sock, help_text) - return False - if parts[0] == "/users": - count = len(clients) - send_to(sock, f"Server: There are {count} user(s) online.") - return False - if parts[0] == "/nick" and len(parts) > 1: - new = parts[1] - if new in usernames: - send_to(sock, f"Server: [red]{new}[/red] is already taken.") - else: - # broadcast leave under old name - broadcast(sock, f"Server: [magenta]{uname}[/magenta] Left the server") - # update lists + with lock: + if sock not in clients: + return False # client disconnected + uname, _, channel = clients[sock] + parts = cmd.split() + if parts[0] == "/help": + help_text = ( + "Server: Available commands:\n" + " /help Show this help message.\n" + " /users List users in the current channel.\n" + " /nick Change your display name.\n" + " /create <#channel> Create a new channel.\n" + " /join <#channel> Join an existing channel.\n" + " /leave Return to the #general channel.\n" + " /close Disconnect from the chat server.\n" + ) + send_to(sock, help_text) + return False + + if parts[0] == "/users": + count = len(channels[channel]) + send_to(sock, f"Server: There are {count} user(s) in {channel}.") + return False + + if parts[0] == "/nick" and len(parts) > 1: + new = parts[1] + if new in usernames: + send_to(sock, f"Server: [red]{new}[/red] is already taken.") + else: + broadcast(sock, f"Server: [magenta]{uname}[/magenta] is now [magenta]{new}[/magenta]", channel) + usernames.remove(uname) + usernames.append(new) + clients[sock][0] = new + send_to(sock, f"Server: Your nick is now {new}") + return False + + if parts[0] == "/create" and len(parts) > 1: + new_channel = parts[1] + if not new_channel.startswith("#"): + send_to(sock, "Server: Channel names must start with #") + elif new_channel in channels: + send_to(sock, f"Server: Channel {new_channel} already exists.") + else: + channels[new_channel] = [] + send_to(sock, f"Server: Channel {new_channel} created.") + return False + + if parts[0] == "/join" and len(parts) > 1: + new_channel = parts[1] + if new_channel not in channels: + send_to(sock, f"Server: Channel {new_channel} does not exist.") + elif new_channel == channel: + send_to(sock, f"Server: You are already in {channel}.") + else: + broadcast(sock, f"Server: [magenta]{uname}[/magenta] Left the channel", channel) + channels[channel].remove(sock) + clients[sock][2] = new_channel + channels[new_channel].append(sock) + user_list = [clients[s][0] for s in channels[new_channel]] + send_to(sock, f"Server: You joined {new_channel}. Users: {user_list}") + broadcast(sock, f"Server: [magenta]{uname}[/magenta] Joined the channel", new_channel) + return False + + if parts[0] == "/leave": + if channel == DEFAULT_CHANNEL: + send_to(sock, f"Server: You can't leave {DEFAULT_CHANNEL}.") + else: + broadcast(sock, f"Server: [magenta]{uname}[/magenta] Left the channel", channel) + channels[channel].remove(sock) + clients[sock][2] = DEFAULT_CHANNEL + channels[DEFAULT_CHANNEL].append(sock) + broadcast(sock, f"Server: [magenta]{uname}[/magenta] Joined the channel", DEFAULT_CHANNEL) + return False + + if parts[0] == "/close": + send_to(sock, MESSAGE_CLOSE) + broadcast(sock, f"Server: [magenta]{uname}[/magenta] Left the server", channel) usernames.remove(uname) - usernames.append(new) - clients[sock][0] = new - send_to(sock, f"Server: Welcome {new}") - broadcast(sock, f"Server: [magenta]{new}[/magenta] Joined the server") - return False - if parts[0] == "/close": - # let client know to close - send_to(sock, MESSAGE_CLOSE) - broadcast(sock, f"Server: [magenta]{uname}[/magenta] Left the server") - usernames.remove(uname) - del clients[sock] - sock.close() - return True - # unknown + channels[channel].remove(sock) + del clients[sock] + sock.close() + return True + + if parts[0] in COMMANDS: + send_to(sock, f"Server: {parts[0]} requires an argument.") + return False def handle_client(sock: socket.socket): - """Per‑connection thread.""" + """Per-connection thread.""" addr = sock.getpeername() # Ask for username send_to(sock, "Server: Enter your username:") - name = sock.recv(1024).decode("utf-8").strip() - # enforce unique - while name in usernames: - send_to(sock, f"Server: [red]{name}[/red] already exists, choose another:") - name = sock.recv(1024).decode("utf-8").strip() - if not name or name == "/close": - send_to(sock, MESSAGE_CLOSE) - sock.close() - return - + while True: + try: + name = sock.recv(1024).decode("utf-8").strip() + except: + return # client disconnected + if not name or name == "/close": + send_to(sock, MESSAGE_CLOSE) + sock.close() + return + with lock: + if name in usernames: + send_to(sock, f"Server: [red]{name}[/red] already exists, choose another:") + else: + break # register - usernames.append(name) - clients[sock] = [name, addr] - send_to(sock, f"Server: Welcome {name}") - broadcast(sock, f"Server: [magenta]{name}[/magenta] Joined the server") + with lock: + usernames.append(name) + clients[sock] = [name, addr, DEFAULT_CHANNEL] + channels[DEFAULT_CHANNEL].append(sock) + + send_to(sock, f"Server: Welcome {name}, you have joined {DEFAULT_CHANNEL}") + broadcast(sock, f"Server: [magenta]{name}[/magenta] Joined the channel", DEFAULT_CHANNEL) # listen while True: @@ -99,22 +173,27 @@ def handle_client(sock: socket.socket): break if data.startswith("/"): - done = handle_commands(data, sock) - if done: + if handle_commands(data, sock): break else: - # normal chat - uname, _ = clients[sock] - broadcast(sock, f"{uname}: {data}") - - # cleanup if fell out - if sock in clients: - uname, _ = clients[sock] - usernames.remove(uname) - del clients[sock] - broadcast(sock, f"Server: [magenta]{uname}[/magenta] Left the server") - try: sock.close() - except: pass + with lock: + if sock in clients: + uname, _, channel = clients[sock] + broadcast(sock, f"{uname}: {data}", channel) + + # cleanup + with lock: + if sock in clients: + uname, _, channel = clients[sock] + broadcast(sock, f"Server: [magenta]{uname}[/magenta] Left the server", channel) + usernames.remove(uname) + if channel in channels and sock in channels[channel]: + channels[channel].remove(sock) + del clients[sock] + try: + sock.close() + except: + pass def server(): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: