diff --git a/.gitignore b/.gitignore index 0cafc8f..0671af0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +users.txt +data # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/README.md b/README.md index 75746b6..818de06 100644 --- a/README.md +++ b/README.md @@ -1 +1,14 @@ -# MultiPersonChat \ No newline at end of file +# MultiPersonChat + +## 运行环境 +- python 3 + +## 运行命令 +- 服务端启动(只能打开一个服务端) +```bash +python server.py +``` +- 客户端启动(支持多个客户端) +```bash +python client.py +``` \ No newline at end of file diff --git a/client.exe b/client.exe deleted file mode 100644 index c1bf1c2..0000000 Binary files a/client.exe and /dev/null differ diff --git a/client.py b/client.py index ef50619..62c4938 100644 --- a/client.py +++ b/client.py @@ -4,14 +4,18 @@ from tkinter import messagebox from threading import Thread import re - +import os +from tkinter import filedialog +from pathlib import Path class Client: """创建客户端的模板类""" def __init__(self): print("初始化tcp多人聊天室客户端") + self.server_ip ='127.0.0.1' + self.server_port = 8888 self.client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.client_socket.connect(('127.0.0.1', 7890)) + self.client_socket.connect((self.server_ip, self.server_port)) def send_login_info(self, username, password): """ @@ -20,14 +24,14 @@ def send_login_info(self, username, password): :param password: 待验证的密码 :return: 验证结果 """ - # 告诉服务器本次请求的类型,“1” 是验证登录 + # 告诉服务器本次请求的类型,"1" 是验证登录 self.client_socket.sendall("1".encode("utf-8")) # 将用户名和密码按照一定规律组合后一起发送给服务器 username_psw = username + "#!#!" + password self.client_socket.sendall(username_psw.encode("utf-8")) - # 获取服务器的返回值,"1"代表通过,“0”代表不通过,再放回True or False + # 获取服务器的返回值,"1"代表通过,"0"代表不通过,再放回True or False check_result = self.client_socket.recv(1024).decode("utf-8") return check_result @@ -42,7 +46,7 @@ def send_register_info(self, username, password, confirm): # 判断两次输入的密码是否一致 if not password == confirm: return "密码不一致,请重新输入!" - # 告诉服务器本次请求类型,“2” 是注册用户 + # 告诉服务器本次请求类型,"2" 是注册用户 self.client_socket.sendall("2".encode("utf-8")) # 将用户名和密码按一定规律组装后发送给服务器 @@ -58,10 +62,84 @@ def send_msg(self, content): 向服务器发送数据 :param content: 待发送的内容 """ - # 告诉服务器本次请求类型,“3” 是发送消息 + # 告诉服务器本次请求类型,"3" 是发送消息 self.client_socket.sendall("3".encode("utf-8")) self.client_socket.sendall(content.encode("utf-8")) + def send_file(self, file_path, username:str, update_progress:None): + """ + 向服务器发送文件 + :param file_path: 文件路径 + """ + try: + # 告诉服务器本次请求类型,"4" 是发送文件 + upload_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + upload_socket.connect((self.server_ip, self.server_port)) + upload_socket.sendall("4".encode("utf-8")) + upload_socket.recv(1024) + + # 发送文件名 + file_name = os.path.basename(file_path)+"#!#!" + file_size = str(os.path.getsize(file_path))+"#!#!" + file_metadata = file_name+file_size+username+"#!#!ready?" + upload_socket.sendall(file_metadata.encode("utf-8")) + upload_socket.recv(1024) + + # 发送文件内容 + with open(file_path, 'rb') as file: + while True: + data = file.read(1024) + if not data: + break + upload_socket.sendall(data) + while True: + percent = int(upload_socket.recv(1024).decode("utf-8").split("#!")[-1]) + if update_progress: + update_progress(percent) + if percent==100: + break + # server_status = self.client_socket.recv(1024).decode("utf-8").split(":") + # if server_status[0]=="failure": + # raise ValueError(server_status[1]) + + print(f"File {file_name} sent successfully") + return True + + except Exception as e: + print(f"Error sending file: {e}") + return False + finally: + upload_socket.close() + + def send_filelists_request(self): + try: + self.client_socket.sendall("5".encode("utf-8")) + except Exception as e: + print(f"Error sending filelists request: {e}") + + def send_download_request(self, file_name, save_path, update_progress=None): + """ + 发送文件下载请求 + :param file_name: 要下载的文件名 + """ + try: + # 告诉服务器本次请求类型,"6" 是文件下载 + download_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + download_socket.connect((self.server_ip, self.server_port)) + download_socket.sendall("6".encode("utf-8")) + + # 发送要下载的文件名 + download_socket.sendall(file_name.encode("utf-8")) + download_socket.recv(1024) + self.__recv_file(download_socket, save_path, update_progress) + + return True + except Exception as e: + print(f"Error sending download request: {e}") + return False + finally: + download_socket.close() + def recv_data(self, size=1024): """ 客户端向服务器接收数据 @@ -69,6 +147,55 @@ def recv_data(self, size=1024): """ return self.client_socket.recv(size).decode("utf-8") + def __recv_file(self, download_socket:socket.socket, save_path, update_progress=None): + """ + 接收文件 + :param save_path: 保存路径 + :return: 是否接收成功 + """ + try: + download_socket.sendall("OK".encode("utf-8")) + file_meta_data = bytes() + while True: + data = download_socket.recv(1024) + file_meta_data += data + meta_data = file_meta_data.split("#!#!".encode("utf-8")) + if len(meta_data)>2: + break + file_name = meta_data[0].decode("utf-8") + print(f"接收文件: {file_name}") + + file_size = int(meta_data[1].decode("utf-8")) + print(f"文件大小: {file_size} bytes") + download_socket.sendall("OK".encode("utf-8")) + + file_data = bytes() + percent=0 + if update_progress: + update_progress(percent) + while True: + data = download_socket.recv(1024) + file_data += data + percent = int(len(file_data)/file_size*100) + if update_progress: + update_progress(percent) + if file_size - len(file_data) <1024: + file_data += download_socket.recv(file_size-len(file_data)) + break + if update_progress: + update_progress(100) + + # 确保data目录存在 + os.makedirs(save_path, exist_ok=True) + + # 接收文件内容并保存 + with open(os.path.join(save_path, file_name), 'wb') as file: + file.write(file_data) + return True + except Exception as e: + print(f"Error receiving file: {e}") + return False + def close(self): """ 关闭客户端与服务器连接的套接字 @@ -328,21 +455,26 @@ def close_callback(self): class MainPanel: """主界面模板类,只需另外传入其他对象的方法""" - def __init__(self, username, client): + def __init__(self, username, client:Client): """初始化实例属性,用户名、在线列表、消息框、消息输入框等""" print("初始化主界面") self.main_frame = Tk() self.client = client self.username = username self.online_list_box = None + self.file_list_box = None self.msg_box = None self.input_box = None + self.progress_label = None + + self.is_uploading= False + self.is_downloading = False def set_panel_position(self): """ 设置聊天主界面在屏幕的位置和大小 """ - width = 800 + width = 1000 height = 500 screen_width = self.main_frame.winfo_screenwidth() screen_height = self.main_frame.winfo_screenheight() @@ -361,7 +493,9 @@ def config_for_mian_panel(self): # 设置窗口关闭按钮时,调用方法,用于退出时关闭socket连接 self.main_frame.protocol("WM_DELETE_WINDOW", self.close_callback) self.main_frame.rowconfigure(1, weight=1) - self.main_frame.columnconfigure(1, weight=1) + self.main_frame.columnconfigure(0, weight=1) + self.main_frame.columnconfigure(1, weight=2) + self.main_frame.columnconfigure(2, weight=1) def set_online_list(self): """ @@ -379,6 +513,59 @@ def set_online_list(self): list_sr_bar['command'] = self.online_list_box.yview self.online_list_box['yscrollcommand'] = list_sr_bar.set + def set_file_list(self): + """ + 设置服务器文件列表 + """ + self.file_list_var = Variable(value=[]) # 设置变量,存储文件列表中的值 + self.file_list_box = Listbox(self.main_frame, selectmode=SINGLE, listvariable=self.file_list_var, bg="lightblue", fg="black", font=("Microsoft Yahei", 12), highlightcolor="white") + self.file_list_box.grid(row=1, column=2, rowspan=2, sticky=N + S + E + W, padx=(5, 10), pady=(0, 5)) + + # 给文件列表设置滚动栏 + file_sr_bar = Scrollbar(self.main_frame) + file_sr_bar.grid(row=1, column=2, sticky=N + S + E, rowspan=2, pady=(0, 5)) + + # 设置滚动效果命令 + file_sr_bar['command'] = self.file_list_box.yview + self.file_list_box['yscrollcommand'] = file_sr_bar.set + + self.file_list_box.bind("<>", self.handle_download_file_func) + + # 添加进度标签 + self.download_progress = StringVar(value="Download: 0%") + self.progress_label = Label(self.main_frame, textvariable=self.download_progress, font=("Microsoft Yahei", 10), bg="lightblue", fg="black") + self.progress_label.grid(row=3, column=2, sticky=W, padx=(5, 10), pady=(5, 0)) + + self.client.send_filelists_request() + + def handle_download_file_func(self, event): + filename_idxs = self.file_list_box.curselection() + if filename_idxs: + if self.is_downloading: + messagebox.showinfo(title="Failure", message=f"You can't download 2 files at same time.") + return + filename = str(self.file_list_box.get(filename_idxs[0])) + home_dir = Path.home() + download_dir = os.path.join(home_dir, "Downloads") + self.is_downloading=True + Thread(target=self.wrapper_for_download_request, args=(filename, download_dir)).start() + + def wrapper_for_download_request(self, filename, download_dir): + try: + self.client.send_download_request(filename, download_dir, self.update_download_progress) + messagebox.showinfo(title="Success", message=f"下载成功!路径为{download_dir}") + finally: + self.is_downloading=False + def update_download_progress(self, progress): + """更新进度标签显示""" + if self.progress_label: + self.download_progress.set(f"Download: {progress}%") + + def update_upload_progress(self, progress): + """更新进度标签显示""" + if self.progress_label: + self.upload_progress.set(f"Upload: {progress}%") + def set_msg_box(self): """ 设置聊天消息显示框 @@ -422,6 +609,14 @@ def set_btn(self): """ Button(self.main_frame, text="Send", bg="lightblue", font=("Microsoft Yahei", 14), fg="black", command=self.send_func).grid(row=3, column=1, pady=5, padx=10, sticky=W, ipady=3, ipadx=10) Button(self.main_frame, text="Clear", bg="lightblue", font=("Microsoft Yahei", 14), fg="black", command=self.clear_input_box).grid(row=3, column=1, pady=5, sticky=W, padx=(110, 0), ipady=3, ipadx=10) + # 在第三行第一列右侧添加上传进度标签 + self.upload_progress = StringVar(value="Upload: 0%") + self.upload_progress_label = Label(self.main_frame, textvariable=self.upload_progress, font=("Microsoft Yahei", 10), bg="lightblue", fg="black") + self.upload_progress_label.grid(row=3, column=1, pady=5, sticky=E, padx=(0, 10)) + + # Upload按钮放在进度标签的左边 + self.upload_button = Button(self.main_frame, text="Upload File", bg="lightblue", font=("Microsoft Yahei", 14), fg="black", command=self.upload_file_func) + self.upload_button.grid(row=3, column=1, pady=5, sticky=E, padx=(0, 180), ipady=3, ipadx=10) def show(self): """主界面布局""" @@ -434,6 +629,7 @@ def show(self): self.set_msg_box() self.set_input_box() self.set_btn() + self.set_file_list() # 启动 self.main_frame.mainloop() @@ -448,10 +644,10 @@ def handle_msg(self): # 获取数据类型:在线用户列表、消息内容 recv_data = self.client.recv_data() if recv_data: - ret = re.match(r"(#![\w]{7}#!)([\s\S]+)", recv_data) + print(recv_data) + ret = re.match(r"(#![\w]+#!)([\s\S]+)", recv_data) option = ret.group(1) print("recieved type: " + option) - print(recv_data) if option == "#!onlines#!": print("获取在线用户列表数据") # 将一次性获取得到的用户名以 “#!”为标记分隔成一个列表 @@ -471,6 +667,15 @@ def handle_msg(self): print("获取用户上下线通知") notice = ret.group(2) # 将通知提取出来 self.show_notice(notice) + elif option == "#!fileupload#!": + print("获取文件上传通知") + upload_msg = ret.group(2) # 将文件上传通知提取出来 + self.show_upload_notice(upload_msg) + elif option == "#!filelist#!": + file_names = ret.group(2).split("#!") + print(file_names) + self.file_list_var.set(file_names) + except Exception as ret: print("接受服务器消息出错,消息接受子线程结束。" + str(ret)) break @@ -494,6 +699,17 @@ def show_notice(self, notice): self.msg_box.config(state=DISABLED) # 文本框设置为不可改变 self.msg_box.see(END) # 滚动到文本框的最底部 + def show_upload_notice(self, upload_msg): + """ + 在消息文本框中显示文件上传通知 + :param upload_msg: 待显示的文件上传通知 + """ + self.msg_box.config(state=NORMAL) # 文本框设置为可改变,实现文本插入 + self.msg_box.insert(END, upload_msg + "\n", "greencolor") + self.msg_box.config(state=DISABLED) # 文本框设置为不可改变 + self.msg_box.see(END) # 滚动到文本框的最底部 + self.client.send_filelists_request() + def set_msg_show_format(self, username, content): """ 将接受到的消息显示在消息文本框中,自己的消息用蓝色的,别人的消息用黑色 @@ -535,8 +751,45 @@ def close_callback(self): self.client.close() self.close() + def upload_file_func(self): + """Upload file button handler""" + if self.is_uploading: + upload_msg = f"[File Upload] You can't upload 2 files at the same time." + self.show_upload_status(upload_msg) + return + file_path = filedialog.askopenfilename() + if file_path: + self.is_uploading=True + Thread(target=self.upload_file, args=(file_path,)).start() + else: + upload_msg = f"[File Upload] file path is empty" + self.show_upload_status(upload_msg) + + def upload_file(self, file_path): + """Handle file upload""" + try: + upload_msg = f"[File Upload] {os.path.basename(file_path)} - uploading..." + self.show_upload_status(upload_msg) + result = self.client.send_file(file_path, self.username, self.update_upload_progress) + if result: + status_msg = f"[File Upload] {os.path.basename(file_path)} - upload completed successfully!" + self.show_upload_status(status_msg) + else: + status_msg = f"[File Upload] {os.path.basename(file_path)} - upload failed!" + self.show_upload_status(status_msg) + except Exception as e: + status_msg = f"[File Upload] {os.path.basename(file_path)} - upload error: {str(e)}" + self.show_upload_status(status_msg) + finally: + self.is_uploading=False + + def show_upload_status(self, message): + """Show upload status in message box""" + self.msg_box.config(state=NORMAL) + self.msg_box.insert(END, message + "\n", "greencolor") + self.msg_box.config(state=DISABLED) + self.msg_box.see(END) if __name__ == "__main__": login_panel = LoginPanel() login_panel.show() - diff --git a/server.exe b/server.exe deleted file mode 100644 index c54f948..0000000 Binary files a/server.exe and /dev/null differ diff --git a/server.py b/server.py index ae20fef..025bdd9 100644 --- a/server.py +++ b/server.py @@ -3,6 +3,8 @@ import hashlib import re import time +import os +from datetime import datetime def encrypt_psw(str): @@ -16,7 +18,7 @@ def encrypt_psw(str): return hl.hexdigest() -online_socket = list() # 在线用户的连接列表,用于群发消息 +online_socket:list[socket.socket] = list() # 在线用户的连接列表,用于群发消息 socket2user = dict() # 存储socket连接和用户名的对应关系 @@ -169,6 +171,127 @@ def handle_msg(new_socket): +content).encode("utf-8")) +def handle_upload(new_socket:socket.socket): + """ + 处理文件上传请求 + :param new_socket: 客户端连接的套接字 + """ + try: + new_socket.sendall("OK".encode("utf-8")) + file_meta_data = bytes() + while True: + data = new_socket.recv(1024) + file_meta_data += data + meta_data = file_meta_data.split("#!#!".encode("utf-8")) + if len(meta_data)>3: + break + file_name = meta_data[0].decode("utf-8") + print(f"接收文件: {file_name}") + file_size = int(meta_data[1].decode("utf-8")) + print(f"文件大小: {file_size} bytes") + username = meta_data[2].decode("utf-8") + print(f"用户名:{username}") + new_socket.sendall("OK".encode("utf-8")) + + file_data = bytes() + percent=0 + while True: + data = new_socket.recv(1024) + file_data += data + percent = int(len(file_data)/file_size*100) + percent = 99 if percent==100 else percent + new_socket.sendall(f"#!{percent}".encode("utf-8")) + if file_size - len(file_data) <1024: + file_data += new_socket.recv(file_size-len(file_data)) + break + new_socket.sendall(f"#!100".encode("utf-8")) + + # 确保data目录存在 + os.makedirs("data", exist_ok=True) + + # 生成带时间戳的新文件名 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + original_name = os.path.basename(file_name) + new_file_name = f"{timestamp}_{original_name}" + # 接收文件内容并保存 + with open(f"data/{new_file_name}", 'wb') as file: + file.write(file_data) + + # 向所有在线用户广播文件上传通知 + upload_notification = f"#{username}上传了文件: {new_file_name}" + for socket in online_socket: + socket.sendall(("#!fileupload#!" + upload_notification).encode("utf-8")) + + except Exception as e: + print(f"文件上传出错: {e}") + # 向客户端发送上传失败消息 + # new_socket.sendall(f"failure:{str(e)}".encode("utf-8")) + + +def handle_file_list(new_socket: socket.socket): + """ + 获取data目录中的文件列表 + :return: 文件列表字符串 + """ + try: + if not os.path.exists("data"): + os.makedirs("data") + return "" + + files = os.listdir("data") + if not files: + return "" + + # 将文件列表组合成一个字符串,用#!分隔 + file_list = "#!filelist#!" + for file in files: + file_list += file + "#!" + file_list = file_list[:-2] + + new_socket.sendall(file_list.encode("utf-8")) + except Exception as e: + print(f"获取文件列表出错: {e}") + return "" + + +def handle_download(new_socket:socket.socket, file_name): + """ + 处理文件下载请求 + :param new_socket: 客户端连接的套接字 + :param file_name: 要下载的文件名 + """ + try: + file_path = f"data/{file_name}" + + # 检查文件是否存在 + # if not os.path.exists(file_path): + # new_socket.sendall("failure:文件不存在".encode("utf-8")) + # return + + # 获取文件大小 + file_size = os.path.getsize(file_path) + new_socket.sendall("#!filedownload#!ready?".encode("utf-8")) + new_socket.recv(1024) #等待对方回应 + + # 发送文件名和大小 + new_socket.sendall(f"{file_name}#!#!{file_size}#!#!ready?".encode("utf-8")) + new_socket.recv(1024) + + # 发送文件内容 + with open(file_path, 'rb') as file: + while True: + data = file.read(1024) + if not data: + break + new_socket.sendall(data) + + print(f"文件 {file_name} 下载成功") + + except Exception as e: + print(f"文件下载出错: {e}") + # new_socket.sendall(f"failure:下载失败 - {str(e)}".encode("utf-8")) + + def handle(new_socket, addr): """ 服务器运行的主框架 @@ -189,6 +312,16 @@ def handle(new_socket, addr): elif req_type == "3": # 发送消息 print("开始处理发送消息请求") handle_msg(new_socket) + elif req_type == "4": # 文件上传 + print("开始处理文件上传请求") + handle_upload(new_socket) + elif req_type == "5": # 获取文件列表 + print("开始处理获取文件列表请求") + handle_file_list(new_socket) + elif req_type == "6": # 文件下载 + print("开始处理文件下载请求") + file_name = new_socket.recv(1024).decode("utf-8") + handle_download(new_socket, file_name) else: break except Exception as ret: @@ -210,7 +343,7 @@ def handle(new_socket, addr): if __name__ == "__main__": try: main_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - main_socket.bind(('127.0.0.1', 7890)) # 服务器绑定的ip和port + main_socket.bind(('127.0.0.1', 8888)) # 服务器绑定的ip和port main_socket.listen(128) # 最大挂起数 print("服务器启动成功,开始监听...") while True: diff --git a/users.txt b/users.txt index 72efb82..e69de29 100644 --- a/users.txt +++ b/users.txt @@ -1,10 +0,0 @@ -zhong -202cb962ac59075b964b07152d234b70 -jia -202cb962ac59075b964b07152d234b70 -ming -202cb962ac59075b964b07152d234b70 -tom -202cb962ac59075b964b07152d234b70 -xiong -e10adc3949ba59abbe56e057f20f883e