Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 38 additions & 10 deletions src/app/chat_client.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"))

Expand Down
17 changes: 13 additions & 4 deletions src/app/constants.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
MAX_CLIENTS_SIZE = 64




MESSAGE_CLOSE = "/close"
MESSAGE_CLOSE = "/close"

DEFAULT_CHANNEL = "#general"

COMMANDS = [
"/close",
"/nick",
"/users",
"/help",
"/join",
"/leave",
"/create",
]
225 changes: 152 additions & 73 deletions src/app/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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 <newname> 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 <newname> 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):
"""Perconnection 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:
Expand All @@ -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:
Expand Down