# -*- coding: utf-8 -*- """ 🔐 混沌加密聊天 v6 - 单文件版 (PyQt5 客户端 + 全部后端) ============================================================ · 后端: 混沌加密/每用户密钥认证/账户SQLite/历史SQLite/媒体持久存储 · 界面: PyQt5 现代圆角UI, 自适应消息气泡 · 两种群: 🔗端到端(P2P) + ☁️中央服务器(解决NAT) · 中央服务器单独运行 server.py 运行: python dvs_enc.py """ import os os.environ.setdefault('PYGAME_HIDE_SUPPORT_PROMPT', '1') import io import sys import time import queue import wave import json import base64 import struct import socket import hashlib import hmac import threading import tempfile import sqlite3 import shutil from datetime import datetime # ---------------- 路径 ---------------- APP_DIR = os.path.dirname(os.path.abspath(__file__)) DATA_DIR = os.path.join(APP_DIR, 'data') try: os.makedirs(DATA_DIR, exist_ok=True) except Exception: DATA_DIR = APP_DIR USERS_DB = os.path.join(DATA_DIR, 'users.db') HISTORY_DB = os.path.join(DATA_DIR, 'chat_history.db') SESSION_FILE = os.path.join(DATA_DIR, 'session.dat') SERVER_DB = os.path.join(DATA_DIR, 'server_state.db') # 媒体持久目录: 文档/ChaosChat/media (用户可在 GUI 清理, 清理后数据消失) MEDIA_DIR = os.path.join(os.path.expanduser('~'), 'Documents', 'ChaosChat', 'media') try: os.makedirs(MEDIA_DIR, exist_ok=True) except Exception: MEDIA_DIR = os.path.join(DATA_DIR, 'media') try: os.makedirs(MEDIA_DIR, exist_ok=True) except Exception: pass # ---------------- 可选依赖 ---------------- try: import tkinter as tk from tkinter import scrolledtext, messagebox, simpledialog, filedialog GUI_AVAILABLE = True except Exception: GUI_AVAILABLE = False try: import pygame PYGAME_AVAILABLE = True except Exception: PYGAME_AVAILABLE = False try: from PIL import Image, ImageTk PIL_AVAILABLE = True except Exception: PIL_AVAILABLE = False # 录音后端: 优先 sounddevice, 其次 pyaudio RECORDER = None try: import sounddevice as sd import numpy as np RECORDER = 'sounddevice' except Exception: RECORDER = None if RECORDER is None: try: import pyaudio RECORDER = 'pyaudio' except Exception: RECORDER = None try: import qrcode QR_AVAILABLE = True except Exception: QR_AVAILABLE = False # ==================== 混沌加密核心 ==================== class ChaosCryptCore: def __init__(self, debug=False): self.debug = debug self.special_encrypt = {'+': '.', '/': "'", '=': ''} self.special_decrypt = {'.': '+', "'": '/'} self.flip_pattern = [1, 1, 0, 0, 1, 0, 0, 0, 1, 0] def _generate_random_alphabet(self, lowercase=False): chars = list("abcdefghijklmnopqrstuvwxyz" if lowercase else "ABCDEFGHIJKLMNOPQRSTUVWXYZ") n = len(chars) for i in range(n-1, 0, -1): j = int.from_bytes(os.urandom(1), 'big') % (i+1) chars[i], chars[j] = chars[j], chars[i] return ''.join(chars) def _generate_random_digit_mapping(self): digits = list("0123456789") pool = list("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") n = len(pool) for i in range(n-1, 0, -1): j = int.from_bytes(os.urandom(1), 'big') % (i+1) pool[i], pool[j] = pool[j], pool[i] return {d: pool[i] for i, d in enumerate(digits)} def _generate_random_equal_mapping(self): chars = list("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") n = len(chars) for i in range(n-1, 0, -1): j = int.from_bytes(os.urandom(1), 'big') % (i+1) chars[i], chars[j] = chars[j], chars[i] return {'0': chars[0], '1': chars[1], '2': chars[2], '3': chars[3]} def _generate_random_flip_pattern(self): return [int.from_bytes(os.urandom(1), 'big') % 2 for _ in range(10)] def _generate_short_key(self, length=512): chars = "0123456789abcdef" return ''.join(chars[int.from_bytes(os.urandom(1), 'big') % 16] for _ in range(length)) def _build_maps(self, upper_mapping, lower_mapping, digit_mapping, equal_mapping): upper_orig = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" lower_orig = "abcdefghijklmnopqrstuvwxyz" enc = {} dec = {} for i in range(26): enc[upper_orig[i]] = upper_mapping[i] enc[lower_orig[i]] = lower_mapping[i] dec[upper_mapping[i]] = upper_orig[i] dec[lower_mapping[i]] = lower_orig[i] digit_rev = {v: k for k, v in digit_mapping.items()} equal_rev = {v: k for k, v in equal_mapping.items()} return enc, dec, digit_rev, equal_rev def _ensure_printable(self, text): res = [] for c in text: v = ord(c) if v == 124: res.append('|') elif v < 32 or v > 126: v = v % 95 + 32 res.append(chr(v)) else: res.append(c) return ''.join(res) def _mix_keys(self, user_password, short_key, target_length): mixed = [] max_len = max(len(user_password), len(short_key)) for i in range(max_len): if i < len(user_password): mixed.append(ord(user_password[i])) if i < len(short_key): mixed.append(ord(short_key[i])) for i in range(len(mixed)): if i % 3 == 0: mixed[i] = (mixed[i] << 1) & 0xFF elif i % 3 == 1: mixed[i] = (mixed[i] >> 1) & 0xFF else: mixed[i] = mixed[i] ^ 0x5A for i in range(0, len(mixed)-3, 4): mixed[i], mixed[i+3] = mixed[i+3], mixed[i] mixed[i+1], mixed[i+2] = mixed[i+2], mixed[i+1] mixed.reverse() res = [] for x in mixed: if x < 32 or x > 126: x = x % 95 + 32 res.append(chr(x)) res_str = ''.join(res) if len(res_str) < target_length: base = res_str final = base it = 0 while len(final) < target_length: if it % 3 == 0: chunk = base[::-1] elif it % 3 == 1: chunk = base[::-1] chunk = ''.join([chr((ord(c) + it) % 95 + 32) for c in chunk]) else: chunk = base[::-1] chunk = ''.join([chr((ord(c) ^ it) % 95 + 32) for c in chunk]) final += chunk it += 1 res_str = final[:target_length] return self._ensure_printable(res_str) def _derive_final_key(self, user_password, short_key, text_length): mixed = self._mix_keys(user_password, short_key, text_length * 2) hex_key = ''.join([f"{ord(c):02x}" for c in mixed]) rev_hex = hex_key[::-1] final = [] for i in range(0, len(rev_hex), 2): if i+1 < len(rev_hex): byte = rev_hex[i:i+2] try: val = int(byte, 16) if val < 32 or val > 126: val = val % 95 + 32 final.append(chr(val)) except Exception: final.append('x') final_str = ''.join(final) if len(final_str) < text_length: base = final_str it = 0 while len(final_str) < text_length: if it % 3 == 0: chunk = base[::-1] elif it % 3 == 1: chunk = base[::-1] chunk = ''.join([chr((ord(c) + it) % 95 + 32) for c in chunk]) else: chunk = base[::-1] chunk = ''.join([chr((ord(c) ^ it) % 95 + 32) for c in chunk]) final_str += chunk it += 1 final_str = final_str[:text_length] return self._ensure_printable(final_str) def _apply_flip(self, text, flip_pattern): res = [] for i, ch in enumerate(text): idx = i % len(flip_pattern) if ch.isalpha() and flip_pattern[idx] == 1: res.append(ch.swapcase()) else: res.append(ch) return ''.join(res) def _xor_encrypt_to_hex(self, text, key): key_len = len(key) res = [] for i, ch in enumerate(text): res.append(f"{ord(ch) ^ ord(key[i % key_len]):02x}") return ''.join(res) def _xor_decrypt_to_string(self, hex_text, key): try: key_len = len(key) res = [] for i in range(0, len(hex_text), 2): if i+1 < len(hex_text): byte = hex_text[i:i+2] val = int(byte, 16) ^ ord(key[(i//2) % key_len]) if val < 32 or val > 126: val = val % 95 + 32 res.append(chr(val)) return ''.join(res) except Exception: return None def _substitute_letters(self, text, enc_map): return ''.join([enc_map.get(c, c) for c in text]) def _inverse_substitute(self, text, dec_map): return ''.join([dec_map.get(c, c) for c in text]) def encrypt(self, plaintext, session_key, params): b64 = base64.b64encode(plaintext.encode('utf-8')).decode('ascii') processed = b64 for old, new in self.special_encrypt.items(): processed = processed.replace(old, new) processed = processed.rstrip('=') enc_map, _, _, _ = self._build_maps( params['upper_mapping'], params['lower_mapping'], params['digit_mapping'], params['equal_mapping'] ) sub = self._substitute_letters(processed, enc_map) flipped = self._apply_flip(sub, params['flip_pattern']) rev = flipped[::-1] eq_count = b64.count('=') eq_char = params['equal_mapping'][str(eq_count)] with_equal = f"{rev}|{eq_char}" final_key = self._derive_final_key( session_key[:16], params['short_key_fragment'], len(with_equal) ) return self._xor_encrypt_to_hex(with_equal, final_key) def decrypt(self, ciphertext, session_key, params): try: final_key = self._derive_final_key( session_key[:16], params['short_key_fragment'], len(ciphertext) // 2 ) decrypted = self._xor_decrypt_to_string(ciphertext, final_key) if decrypted is None: return None if '|' in decrypted: main, eq_char = decrypted.split('|') eq_rev = {v: k for k, v in params['equal_mapping'].items()} eq_count = int(eq_rev.get(eq_char, '0')) else: main = decrypted eq_count = 0 rev = main[::-1] flipped = self._apply_flip(rev, params['flip_pattern']) _, dec_map, _, _ = self._build_maps( params['upper_mapping'], params['lower_mapping'], params['digit_mapping'], params['equal_mapping'] ) sub = self._inverse_substitute(flipped, dec_map) for old, new in self.special_decrypt.items(): sub = sub.replace(old, new) b64_str = sub + '=' * eq_count return base64.b64decode(b64_str.encode('ascii')).decode('utf-8') except Exception: return None # ==================== 会话密钥 (共享加密) ==================== class SecureSession: """基于 ChaosCryptCore 的共享会话加密 (群聊同密钥)""" def __init__(self): self.core = ChaosCryptCore() self.session_key = None self.params = None def generate(self): self.session_key = os.urandom(32).hex() self.params = { 'upper_mapping': self.core._generate_random_alphabet(False), 'lower_mapping': self.core._generate_random_alphabet(True), 'digit_mapping': self.core._generate_random_digit_mapping(), 'equal_mapping': self.core._generate_random_equal_mapping(), 'flip_pattern': self.core._generate_random_flip_pattern(), 'short_key_fragment': self.core._generate_short_key(32), 'generated_at': datetime.now().isoformat() } return self.session_key def export_key(self): data = json.dumps({'session_key': self.session_key, 'params': self.params}, ensure_ascii=False) return base64.urlsafe_b64encode(data.encode('utf-8')).decode('ascii') def import_key(self, key_str): try: raw = base64.urlsafe_b64decode(key_str.encode('ascii')) data = json.loads(raw.decode('utf-8')) self.session_key = data['session_key'] self.params = data['params'] return True except Exception: return False def has_key(self): return self.session_key is not None and self.params is not None def encrypt(self, plaintext): if not self.has_key(): raise ValueError("会话密钥未建立") nonce = os.urandom(16).hex() msg_key = hashlib.sha256((self.session_key + nonce).encode('utf-8')).hexdigest() cipher = self.core.encrypt(plaintext, msg_key, self.params) hmac = hashlib.sha256((cipher + self.session_key).encode('utf-8')).hexdigest()[:16] return json.dumps({ 'nonce': nonce, 'ciphertext': cipher, 'hmac': hmac, 'ts': int(time.time()) }, ensure_ascii=False) def decrypt(self, packet_json): try: pkt = json.loads(packet_json) hmac_calc = hashlib.sha256((pkt['ciphertext'] + self.session_key).encode('utf-8')).hexdigest()[:16] if hmac_calc != pkt['hmac']: return None msg_key = hashlib.sha256((self.session_key + pkt['nonce']).encode('utf-8')).hexdigest() return self.core.decrypt(pkt['ciphertext'], msg_key, self.params) except Exception: return None # ---- 二进制数据加密 (文件/图片/语音) ---- def _keystream(self, nonce, length): return hashlib.shake_256((self.session_key + nonce).encode('utf-8')).digest(length) @staticmethod def _fast_xor(a, b, chunk=1 << 20): out = bytearray() n = len(a) for i in range(0, n, chunk): size = min(chunk, n - i) x = int.from_bytes(a[i:i+size], 'big') ^ int.from_bytes(b[i:i+size], 'big') out += x.to_bytes(size, 'big') return bytes(out) def encrypt_bytes(self, data): if not self.has_key(): raise ValueError("会话密钥未建立") nonce = os.urandom(16).hex() ks = self._keystream(nonce, len(data)) cipher = self._fast_xor(data, ks) hmac = hashlib.sha256( b'BIN|' + self.session_key.encode('utf-8') + b'|' + nonce.encode('utf-8') + b'|' + cipher).hexdigest() return json.dumps({'alg': 'xor', 'nonce': nonce, 'data': base64.b64encode(cipher).decode('ascii'), 'hmac': hmac}, ensure_ascii=False) def decrypt_bytes(self, packet): try: pkt = json.loads(packet) nonce = pkt['nonce'] cipher = base64.b64decode(pkt['data']) hmac_calc = hashlib.sha256( b'BIN|' + self.session_key.encode('utf-8') + b'|' + nonce.encode('utf-8') + b'|' + cipher).hexdigest() if hmac_calc != pkt['hmac']: return None ks = self._keystream(nonce, len(cipher)) return self._fast_xor(cipher, ks) except Exception: return None def auth_secret(self): """从会话密钥派生的群认证密钥 (证明是群成员)""" return hashlib.sha256(b'CHAOS_AUTH|' + self.session_key.encode('utf-8')).hexdigest() def user_key_enc(self): """用于加密下发每用户密钥的密钥 (客户端/服务器各自计算一致)""" return hashlib.sha256(b'CHAOS_USERKEY_ENC|' + self.session_key.encode('utf-8')).hexdigest() # ==================== 传输协议 (帧封装) ==================== MAX_FRAME = 256 * 1024 * 1024 def send_msg(sock, obj): payload = json.dumps(obj, ensure_ascii=False).encode('utf-8') sock.sendall(struct.pack('>I', len(payload)) + payload) def recv_exact(sock, n): data = b'' while len(data) < n: try: chunk = sock.recv(n - len(data)) except socket.timeout: continue except Exception: return None if not chunk: return None data += chunk return data def recv_msg(sock): header = recv_exact(sock, 4) if header is None: return None length = struct.unpack('>I', header)[0] if length <= 0 or length > MAX_FRAME: return None payload = recv_exact(sock, length) if payload is None: return None try: return json.loads(payload.decode('utf-8')) except Exception: return None # ==================== 通用工具 ==================== def stream_xor(data, key_bytes): """shake256 密钥流异或 (用于加密下发每用户密钥)""" ks = hashlib.shake_256(key_bytes).digest(len(data)) out = bytearray() n = len(data) chunk = 1 << 20 for i in range(0, n, chunk): size = min(chunk, n - i) x = int.from_bytes(data[i:i+size], 'big') ^ int.from_bytes(ks[i:i+size], 'big') out += x.to_bytes(size, 'big') return bytes(out) def gen_mid(): return f"{int(time.time()*1000)}-{os.urandom(4).hex()}" def fmt_time(ts): return datetime.fromtimestamp(ts).strftime('%H:%M:%S') # ==================== 文件类型识别 ==================== FILE_ICONS = { 'image': '🖼️', 'audio': '🎵', 'video': '🎬', 'archive': '📦', 'exe': '🛠️', 'lnk': '🔗', 'link': '🔗', 'doc': '📝', 'sheet': '📊', 'slide': '📽️', 'pdf': '📃', 'code': '💻', 'text': '📄', 'default': '📄', } def file_icon(ext): """按扩展名返回可识别图标""" e = (ext or '').lower().lstrip('.') if e in ('png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'ico'): return FILE_ICONS['image'] if e in ('wav', 'mp3', 'ogg', 'flac', 'aac', 'm4a'): return FILE_ICONS['audio'] if e in ('mp4', 'avi', 'mkv', 'mov', 'wmv', 'flv'): return FILE_ICONS['video'] if e in ('zip', 'rar', '7z', 'tar', 'gz', 'bz2'): return FILE_ICONS['archive'] if e in ('exe', 'msi', 'bat', 'cmd'): return FILE_ICONS['exe'] if e == 'lnk': return FILE_ICONS['lnk'] if e in ('doc', 'docx', 'rtf', 'odt'): return FILE_ICONS['doc'] if e in ('xls', 'xlsx', 'csv', 'ods'): return FILE_ICONS['sheet'] if e in ('ppt', 'pptx', 'odp'): return FILE_ICONS['slide'] if e == 'pdf': return FILE_ICONS['pdf'] if e in ('py', 'js', 'html', 'css', 'c', 'cpp', 'java', 'go', 'rs', 'json', 'xml'): return FILE_ICONS['code'] if e in ('txt', 'md', 'log'): return FILE_ICONS['text'] return FILE_ICONS['default'] # ==================== 媒体持久存储 ==================== def save_media(data, ext='.bin'): """保存媒体文件到 文档/ChaosChat/media, 返回绝对路径""" fname = f"{int(time.time()*1000)}-{os.urandom(4).hex()}{ext}" path = os.path.join(MEDIA_DIR, fname) try: with open(path, 'wb') as f: f.write(data) return path except Exception: return '' def delete_media(path): try: if path and os.path.exists(path): os.remove(path) except Exception: pass def clear_media_cache(): """清空媒体缓存目录 (数据不可恢复)""" if not os.path.isdir(MEDIA_DIR): return 0 count = 0 for name in os.listdir(MEDIA_DIR): p = os.path.join(MEDIA_DIR, name) try: if os.path.isfile(p): os.remove(p) count += 1 except Exception: pass return count # ==================== 账户数据库 (SQLite) ==================== class AccountDB: """账户 + 会话 + 已保存群""" def __init__(self, db_path=USERS_DB): self.path = db_path self.conn = sqlite3.connect(db_path, check_same_thread=False) self.conn.row_factory = sqlite3.Row self.lock = threading.Lock() self._init() def _init(self): with self.lock: cur = self.conn.cursor() cur.execute('''CREATE TABLE IF NOT EXISTS accounts( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, nickname TEXT, salt TEXT NOT NULL, pass_hash TEXT NOT NULL, created_at REAL)''') cur.execute('''CREATE TABLE IF NOT EXISTS sessions( id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER NOT NULL, token TEXT UNIQUE NOT NULL, history_key TEXT, created_at REAL)''') scols = [r[1] for r in cur.execute('PRAGMA table_info(sessions)').fetchall()] if 'history_key' not in scols: cur.execute('ALTER TABLE sessions ADD COLUMN history_key TEXT') cur.execute('''CREATE TABLE IF NOT EXISTS saved_groups( id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER NOT NULL, group_key TEXT NOT NULL, host TEXT, port INTEGER, session_key TEXT, nickname TEXT, is_host INTEGER DEFAULT 0, user_key TEXT, UNIQUE(account_id, group_key))''') cols = [r[1] for r in cur.execute('PRAGMA table_info(saved_groups)').fetchall()] if 'user_key' not in cols: cur.execute('ALTER TABLE saved_groups ADD COLUMN user_key TEXT') self.conn.commit() # ---- 密码哈希 (PBKDF2) ---- @staticmethod def hash_password(password, salt_hex): salt = bytes.fromhex(salt_hex) return hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 120000).hex() def register(self, username, password): salt = os.urandom(16).hex() ph = self.hash_password(password, salt) try: with self.lock: cur = self.conn.cursor() cur.execute('INSERT INTO accounts(username, nickname, salt, pass_hash, created_at) VALUES(?,?,?,?,?)', (username, username, salt, ph, time.time())) self.conn.commit() return True, 'ok' except sqlite3.IntegrityError: return False, '该用户名已被注册' def login(self, username, password): with self.lock: cur = self.conn.cursor() row = cur.execute('SELECT * FROM accounts WHERE username=?', (username,)).fetchone() if row is None: return None, '用户不存在' ph = self.hash_password(password, row['salt']) if ph != row['pass_hash']: return None, '密码错误' return row, 'ok' def create_session(self, account_id): token = os.urandom(32).hex() with self.lock: cur = self.conn.cursor() cur.execute('DELETE FROM sessions WHERE account_id=?', (account_id,)) cur.execute('INSERT INTO sessions(account_id, token, created_at) VALUES(?,?,?)', (account_id, token, time.time())) self.conn.commit() return token def validate_session(self, token): with self.lock: cur = self.conn.cursor() row = cur.execute('''SELECT a.* FROM sessions s JOIN accounts a ON a.id = s.account_id WHERE s.token=?''', (token,)).fetchone() return row def update_nickname(self, account_id, nickname): with self.lock: cur = self.conn.cursor() cur.execute('UPDATE accounts SET nickname=? WHERE id=?', (nickname, account_id)) self.conn.commit() # ---- 已保存的群 ---- def save_group(self, account_id, group_key, host, port, session_key, nickname, is_host, user_key=''): with self.lock: cur = self.conn.cursor() cur.execute('''INSERT INTO saved_groups(account_id, group_key, host, port, session_key, nickname, is_host, user_key) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(account_id, group_key) DO UPDATE SET host=excluded.host, port=excluded.port, session_key=excluded.session_key, nickname=excluded.nickname, is_host=excluded.is_host, user_key=excluded.user_key''', (account_id, group_key, host, port, session_key, nickname, 1 if is_host else 0, user_key or '')) self.conn.commit() def load_groups(self, account_id): with self.lock: cur = self.conn.cursor() return cur.execute('SELECT * FROM saved_groups WHERE account_id=?', (account_id,)).fetchall() def remove_group(self, account_id, group_key): with self.lock: cur = self.conn.cursor() cur.execute('DELETE FROM saved_groups WHERE account_id=? AND group_key=?', (account_id, group_key)) self.conn.commit() # ==================== 历史记录数据库 (SQLite) ==================== class HistoryDB: """群聊记录: 文本存内容, 文件/语音只存路径""" def __init__(self, db_path=HISTORY_DB): self.conn = sqlite3.connect(db_path, check_same_thread=False) self.conn.row_factory = sqlite3.Row self.lock = threading.Lock() self._init() def _init(self): with self.lock: cur = self.conn.cursor() cur.execute('''CREATE TABLE IF NOT EXISTS messages( id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER NOT NULL, group_key TEXT NOT NULL, mid TEXT, sender TEXT, mtype TEXT, content TEXT, ts REAL, is_mine INTEGER DEFAULT 0, withdrawn INTEGER DEFAULT 0)''') cur.execute('CREATE INDEX IF NOT EXISTS idx_msg ON messages(account_id, group_key, id)') self.conn.commit() @staticmethod def serialize_content(msg): if msg.type in ('text', 'system'): return str(msg.content) if isinstance(msg.content, dict): d = dict(msg.content) d.pop('raw', None) # 不入库: 数据存文档目录, 库中只记路径 return json.dumps(d, ensure_ascii=False) return str(msg.content) @staticmethod def deserialize_content(mtype, content): if mtype in ('text', 'system'): return content try: return json.loads(content) except Exception: return content def save(self, account_id, group_key, msg): with self.lock: cur = self.conn.cursor() cur.execute('''INSERT INTO messages(account_id, group_key, mid, sender, mtype, content, ts, is_mine, withdrawn) VALUES(?,?,?,?,?,?,?,?,?)''', (account_id, group_key, msg.mid, msg.sender, msg.type, self.serialize_content(msg), msg.timestamp, 1 if msg.is_mine else 0, 1 if msg.withdrawn else 0)) self.conn.commit() def load(self, account_id, group_key, limit=500): with self.lock: cur = self.conn.cursor() return cur.execute('''SELECT * FROM ( SELECT * FROM messages WHERE account_id=? AND group_key=? ORDER BY id DESC LIMIT ?) ORDER BY id ASC''', (account_id, group_key, limit)).fetchall() def delete_by_mid(self, account_id, group_key, mid): with self.lock: cur = self.conn.cursor() cur.execute('DELETE FROM messages WHERE account_id=? AND group_key=? AND mid=?', (account_id, group_key, mid)) self.conn.commit() def clear_group(self, account_id, group_key): with self.lock: cur = self.conn.cursor() cur.execute('DELETE FROM messages WHERE account_id=? AND group_key=?', (account_id, group_key)) self.conn.commit() # ==================== 语音管理 ==================== class VoiceManager: def __init__(self, on_status=None): self.on_status = on_status self.rate = 44100 self.channels = 1 self.recording = False self._thread = None self._frames = [] self._result = None self.last_voice = None self.last_voice_from = '' def _status(self, text): if self.on_status: try: self.on_status(text) except Exception: pass def _make_wav(self, pcm_bytes): buf = io.BytesIO() with wave.open(buf, 'wb') as wf: wf.setnchannels(self.channels) wf.setsampwidth(2) wf.setframerate(self.rate) wf.writeframes(pcm_bytes) return buf.getvalue() def _record_worker(self): self._result = None try: if RECORDER == 'sounddevice': self._frames = [] def callback(indata, frames, time_info, status): if self.recording: self._frames.append(indata.copy()) with sd.InputStream(samplerate=self.rate, channels=1, callback=callback, dtype='float32'): while self.recording: time.sleep(0.05) if self._frames: audio = np.concatenate(self._frames, axis=0) pcm = (np.clip(audio, -1.0, 1.0) * 32767).astype(np.int16).tobytes() self._result = self._make_wav(pcm) elif RECORDER == 'pyaudio': p = pyaudio.PyAudio() stream = p.open(format=pyaudio.paInt16, channels=1, rate=self.rate, input=True, frames_per_buffer=1024) frames = [] while self.recording: data = stream.read(1024, exception_on_overflow=False) frames.append(data) stream.stop_stream() stream.close() p.terminate() if frames: self._result = self._make_wav(b''.join(frames)) if self._result: self._status(f"✅ 录音完成 ({len(self._result) // 1024} KB)") except Exception as e: self._result = None self._status(f"❌ 录音失败: {e}") def start_recording(self): if RECORDER is None: self._status("⚠️ 请安装 sounddevice 或 pyaudio 才能录音") return False if self.recording: return False self.recording = True self._thread = threading.Thread(target=self._record_worker, daemon=True) self._thread.start() self._status("🎤 录音中... 再点一次停止并发送") return True def stop_recording(self): if not self.recording: return None self.recording = False if self._thread: self._thread.join(timeout=10) result = self._result self._result = None if result: self.last_voice = result self.last_voice_from = '' return result def play(self, wav_bytes, from_user=''): if not wav_bytes: return False if not PYGAME_AVAILABLE: self._status("⚠️ 未安装 pygame,无法播放语音") return False try: if not pygame.mixer.get_init(): pygame.mixer.init(frequency=self.rate, size=-16, channels=self.channels) fd, path = tempfile.mkstemp(suffix='.wav') with os.fdopen(fd, 'wb') as f: f.write(wav_bytes) sound = pygame.mixer.Sound(path) sound.play() def _cleanup(): for _ in range(300): if not pygame.mixer.get_busy(): break time.sleep(0.1) try: os.remove(path) except Exception: pass threading.Thread(target=_cleanup, daemon=True).start() self.last_voice = wav_bytes self.last_voice_from = from_user if from_user: self._status(f"🔊 播放来自 {from_user} 的语音") return True except Exception as e: self._status(f"❌ 播放失败: {e}") return False # ==================== 消息模型 ==================== class Message: def __init__(self, mid, group, sender, msg_type, content, timestamp, is_mine=False): self.mid = mid self.group = group self.sender = sender self.type = msg_type # text / image / voice / audio / file / system self.content = content # 文本 str, 或 {'filename','size','path','ext'} self.timestamp = timestamp self.is_mine = is_mine self.read = False self.withdrawn = False def is_withdrawable(self, now=None): now = now or time.time() return self.is_mine and not self.withdrawn and (now - self.timestamp) <= 120 def get_data_bytes(self): """返回文件/图片/语音的原始字节 (从磁盘路径读取)""" if isinstance(self.content, dict): p = self.content.get('path') if p and os.path.isfile(p): try: with open(p, 'rb') as f: return f.read() except Exception: return b'' return b'' # ==================== 服务器状态数据库 (踢人标记/群名/公告) ==================== class ServerDB: """服务器级持久状态: 被踢账户 + 群名 + 公告 (重启不丢)""" def __init__(self, db_path=SERVER_DB): self.conn = sqlite3.connect(db_path, check_same_thread=False) self.conn.row_factory = sqlite3.Row self.lock = threading.Lock() self._init() def _init(self): with self.lock: cur = self.conn.cursor() cur.execute("""CREATE TABLE IF NOT EXISTS kicked( server_key TEXT NOT NULL, account TEXT NOT NULL, kicked_at REAL, PRIMARY KEY(server_key, account))""") cur.execute("""CREATE TABLE IF NOT EXISTS server_info( server_key TEXT PRIMARY KEY, group_name TEXT, announcement TEXT)""") cur.execute("""CREATE TABLE IF NOT EXISTS user_keys( server_key TEXT NOT NULL, account TEXT NOT NULL, user_key TEXT, PRIMARY KEY(server_key, account))""") self.conn.commit() def is_kicked(self, server_key, account): with self.lock: cur = self.conn.cursor() row = cur.execute('SELECT 1 FROM kicked WHERE server_key=? AND account=?', (server_key, account)).fetchone() return row is not None def add_kicked(self, server_key, account): with self.lock: cur = self.conn.cursor() cur.execute('INSERT OR REPLACE INTO kicked(server_key, account, kicked_at) VALUES(?,?,?)', (server_key, account, time.time())) self.conn.commit() def remove_kicked(self, server_key, account): with self.lock: cur = self.conn.cursor() cur.execute('DELETE FROM kicked WHERE server_key=? AND account=?', (server_key, account)) self.conn.commit() # ---- 每用户密钥 (身份认证) ---- def get_user_key(self, server_key, account): with self.lock: cur = self.conn.cursor() row = cur.execute('SELECT user_key FROM user_keys WHERE server_key=? AND account=?', (server_key, account)).fetchone() return row['user_key'] if row else None def set_user_key(self, server_key, account, user_key): with self.lock: cur = self.conn.cursor() cur.execute('INSERT OR REPLACE INTO user_keys(server_key, account, user_key) VALUES(?,?,?)', (server_key, account, user_key)) self.conn.commit() def get_info(self, server_key): with self.lock: cur = self.conn.cursor() row = cur.execute('SELECT * FROM server_info WHERE server_key=?', (server_key,)).fetchone() if row: return row['group_name'] or '', row['announcement'] or '' return '', '' def save_info(self, server_key, group_name, announcement): with self.lock: cur = self.conn.cursor() cur.execute("""INSERT INTO server_info(server_key, group_name, announcement) VALUES(?,?,?) ON CONFLICT(server_key) DO UPDATE SET group_name=excluded.group_name, announcement=excluded.announcement""", (server_key, group_name or '', announcement or '')) self.conn.commit() # ==================== 多用户服务器 v5 ==================== class ChatServer: """多用户聊天服务器: 群主管理(踢人/群名/公告) + 消息广播""" def __init__(self, on_status=None, server_key='default', server_db=None, auth_secret=None, server_secret=None): self.sock = None self.running = False self.clients = {} # cid -> {socket, nickname, account, addr, lock, is_host, pending, user_key} self.next_id = 1 self.lock = threading.Lock() self.on_status = on_status self.host = '0.0.0.0' self.port = 8888 self.host_cid = None # 第一个加入的客户端 = 群主 self.announcement = '' self.group_name = '' self.server_key = server_key self.server_db = server_db self.auth_secret = auth_secret self.user_key_enc = None if server_secret: self.auth_secret = hashlib.sha256( b'CHAOS_AUTH|' + server_secret.encode('utf-8')).hexdigest() self.user_key_enc = hashlib.sha256( b'CHAOS_USERKEY_ENC|' + server_secret.encode('utf-8')).hexdigest() self.mid_owner = {} # mid -> (account, ts) 消息归属, 用于撤回校验 def _status(self, text): if self.on_status: try: self.on_status(text) except Exception: pass def start(self, port=8888): try: if self.server_db: gn, an = self.server_db.get_info(self.server_key) self.group_name = gn or '' self.announcement = an or '' self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.sock.bind((self.host, port)) self.sock.listen(10) self.running = True self.port = port threading.Thread(target=self._accept_loop, daemon=True).start() self._status(f"🔄 服务器已启动,端口 {port},等待连接...") return True except Exception as e: self._status(f"❌ 服务器启动失败: {e}") return False def stop(self): self.running = False if self.sock: try: self.sock.close() except Exception: pass self.sock = None with self.lock: socks = [c['socket'] for c in self.clients.values()] self.clients.clear() for s in socks: try: s.close() except Exception: pass def _accept_loop(self): while self.running: try: client, addr = self.sock.accept() with self.lock: cid = self.next_id self.next_id += 1 self.clients[cid] = { 'socket': client, 'nickname': f'用户{cid}', 'account': '', 'addr': addr, 'lock': threading.Lock(), 'is_host': False } self._status(f"✅ 新连接: {addr[0]}:{addr[1]}") threading.Thread(target=self._client_handler, args=(cid,), daemon=True).start() except Exception: break def _client_handler(self, cid): client = self.clients[cid]['socket'] # 第一步: join (含账户 + 昵称) try: msg = recv_msg(client) if msg and msg.get('type') == 'join': account = str(msg.get('account', f'用户{cid}'))[:24] nickname = str(msg.get('nickname') or msg.get('username') or account)[:20] with self.lock: if cid in self.clients: c = self.clients[cid] c['account'] = account c['nickname'] = nickname if self.host_cid is None: self.host_cid = cid c['is_host'] = True # 加入认证 ①: 必须持有群密钥 (证明是群成员) if self.auth_secret: expect = hmac.new(self.auth_secret.encode('utf-8'), account.encode('utf-8'), hashlib.sha256).hexdigest() if msg.get('auth') != expect: self._status(f"⚠️ 拒绝未认证的加入请求: {account}") self._remove_client(cid) return # 加入认证 ②: 每用户密钥 (防止成员冒充他人) user_key = None if self.server_db: user_key = self.server_db.get_user_key(self.server_key, account) if user_key: expect2 = hmac.new(user_key.encode('utf-8'), ('JOIN:' + account).encode('utf-8'), hashlib.sha256).hexdigest() if msg.get('user_auth') != expect2: self._status(f"⚠️ 拒绝身份认证失败(冒充): {account}") self._remove_client(cid) return else: # 新账户: 签发本人密钥并加密下发 if self.user_key_enc: user_key = os.urandom(16).hex() if self.server_db: self.server_db.set_user_key(self.server_key, account, user_key) enc = base64.b64encode( stream_xor(user_key.encode('utf-8'), bytes.fromhex(self.user_key_enc))).decode('ascii') self._send_to(cid, {'type': 'user_key_issued', 'data': enc}) if user_key: with self.lock: if cid in self.clients: self.clients[cid]['user_key'] = user_key # 被踢过的账户: 需群主同意 if self.server_db and self.server_db.is_kicked(self.server_key, account): with self.lock: if cid in self.clients: self.clients[cid]['pending'] = True self._status(f"⏳ {nickname} 曾被移出, 等待群主同意") self._send_to(cid, {'type': 'need_approval', 'reason': '你曾被移出本群,需群主同意后才能加入'}) if self.host_cid is not None: self._send_to(self.host_cid, {'type': 'approval_request', 'account': account, 'nickname': nickname}) if not self._wait_approval(cid): self._remove_client(cid) return with self.lock: if cid in self.clients: self.clients[cid]['pending'] = False self._send_to(cid, {'type': 'approval_granted'}) self._status(f"✅ {nickname} 经群主同意重新加入") self._broadcast({'type': 'system', 'text': f"✅ {nickname} 经群主同意重新加入"}, exclude=cid) self._broadcast_users() # 给成员发群信息 self._send_to(cid, {'type': 'group_info', 'group_name': self.group_name, 'announcement': self.announcement}) self._status(f"👋 {nickname} 加入了聊天室") self._broadcast({'type': 'system', 'text': f"👋 {nickname} 加入了聊天室"}, exclude=cid) self._broadcast_users() else: self._remove_client(cid) return except Exception: self._remove_client(cid) return # 第二步: 消息循环 while self.running: if cid not in self.clients: break try: msg = recv_msg(client) if msg is None: break self._handle_msg(cid, msg) except Exception: break self._remove_client(cid) def _handle_msg(self, cid, msg): mtype = msg.get('type') with self.lock: if cid not in self.clients: return c = self.clients[cid] uname = c['nickname'] account = c.get('account', '') is_host = c.get('is_host', False) user_key = c.get('user_key') # 来源认证: 校验每用户签名 + 账户绑定 (服务器只转发真实用户的消息) secret = user_key or self.auth_secret if secret and not self._verify_sig(msg, account, secret): self._status(f"⚠️ 拒绝未认证的消息 (来自 {uname})") return if mtype == 'rename': new_name = str(msg.get('new_name', uname))[:20] with self.lock: if cid in self.clients: self.clients[cid]['nickname'] = new_name self._status(f"✏️ {uname} 改名为 {new_name}") self._broadcast({'type': 'system', 'text': f"✏️ {uname} 改名为 {new_name}"}, exclude=cid) self._send_to(cid, {'type': 'system', 'text': f"✅ 你已改名为 {new_name}"}) self._broadcast_users() elif mtype == 'set_announcement': if not is_host: return self.announcement = str(msg.get('text', ''))[:500] if self.server_db: self.server_db.save_info(self.server_key, self.group_name, self.announcement) self._broadcast({'type': 'announcement', 'text': self.announcement, 'ts': int(time.time())}, exclude=None) self._status("📢 公告已更新") elif mtype == 'set_group_name': if not is_host: return self.group_name = str(msg.get('name', ''))[:30] if self.server_db: self.server_db.save_info(self.server_key, self.group_name, self.announcement) self._broadcast({'type': 'group_name', 'name': self.group_name}, exclude=None) self._status(f"🏷️ 群名更新: {self.group_name}") elif mtype == 'kick': if not is_host: return target = str(msg.get('target', msg.get('account', ''))) kick_cid = None with self.lock: for cid2, cc in self.clients.items(): if cc.get('account') == target and cid2 != cid: kick_cid = cid2 break if kick_cid is not None: if self.server_db: self.server_db.add_kicked(self.server_key, target) self._send_to(kick_cid, {'type': 'kick', 'reason': '你已被群主移出群'}) self._broadcast({'type': 'system', 'text': f"🚫 {target} 已被群主移出群"}, exclude=None) self._remove_client(kick_cid) elif mtype == 'approve': if not is_host: return target = str(msg.get('target', msg.get('account', ''))) decision = msg.get('decision', 'deny') with self.lock: target_cid = None for cid2, cc in self.clients.items(): if cc.get('account') == target and cc.get('pending'): target_cid = cid2 break if target_cid is not None: with self.lock: if target_cid in self.clients: self.clients[target_cid]['approve_decision'] = decision if decision == 'grant': self._status(f"✅ 群主同意 {target} 加入") else: self._status(f"❌ 群主拒绝 {target} 加入") elif mtype == 'withdraw': # 只有消息原作者才能撤回 mid = msg.get('mid', '') owner = self.mid_owner.get(mid) if owner and owner[0] == account: msg['from'] = uname self._broadcast(msg, exclude=cid) else: self._status(f"⚠️ 拒绝他人消息的撤回: {uname}") else: # 普通消息 (chat / file / voice 等): 记录归属并转发 mid = msg.get('mid') if mid: self.mid_owner[mid] = (account, time.time()) self._prune_mid_owner() msg['from'] = uname msg['ts'] = msg.get('ts', int(time.time())) self._broadcast(msg, exclude=cid) def _verify_sig(self, msg, account, secret): """校验消息 HMAC 签名 + 账户绑定 (secret 为每用户密钥或群密钥)""" try: sig = msg.get('sig') if not sig or not secret: return False canonical = json.dumps( {k: v for k, v in msg.items() if k not in ('sig', 'from', 'ts')}, sort_keys=True, ensure_ascii=False) expect = hmac.new(secret.encode('utf-8'), canonical.encode('utf-8'), hashlib.sha256).hexdigest() return (sig == expect) and (msg.get('account') == account) except Exception: return False def _prune_mid_owner(self): now = time.time() expired = [m for m, (a, t) in self.mid_owner.items() if now - t > 300] for m in expired: self.mid_owner.pop(m, None) def _wait_approval(self, cid): """等待群主同意; 返回 True=同意, False=拒绝/断开""" client = self.clients[cid]['socket'] try: client.settimeout(0.5) except Exception: pass try: while self.running and cid in self.clients: with self.lock: decision = self.clients[cid].get('approve_decision') if cid in self.clients else None if decision == 'grant': return True if decision == 'deny': self._send_to(cid, {'type': 'approval_denied', 'reason': '群主拒绝了你的加入'}) return False try: data = client.recv(1024) if not data: return False except socket.timeout: continue except Exception: return False return False finally: try: client.settimeout(None) except Exception: pass def _send_to(self, cid, msg): with self.lock: c = self.clients.get(cid) if c: try: with c['lock']: send_msg(c['socket'], msg) except Exception: pass def _remove_client(self, cid): with self.lock: entry = self.clients.pop(cid, None) if cid == self.host_cid: self.host_cid = None if entry: try: entry['socket'].close() except Exception: pass uname = entry['nickname'] self._status(f"🔌 {uname} 已断开") self._broadcast({'type': 'system', 'text': f"👋 {uname} 离开了聊天室"}, exclude=cid) self._broadcast_users() def _broadcast_users(self): with self.lock: users = [{'name': c['nickname'], 'account': c.get('account', '')} for c in self.clients.values()] self._broadcast({'type': 'users', 'users': users}, exclude=None) def _broadcast(self, msg, exclude=None): with self.lock: targets = [c for cid, c in self.clients.items() if cid != exclude] for c in targets: try: with c['lock']: send_msg(c['socket'], msg) except Exception: pass # ==================== 聊天客户端 v5 ==================== class ChatClient: def __init__(self, on_message=None, on_status=None, on_users=None, on_file=None, on_voice=None, on_system=None, on_withdraw=None, on_announcement=None, on_group_name=None, on_kick=None, on_group_info=None, on_need_approval=None, on_approval_granted=None, on_approval_denied=None, on_approval_request=None, on_user_key_issued=None, on_read=None): self.sock = None self.username = '' self.running = False self.connected = False self.user_key = None self.user_key_enc = None self.on_message = on_message self.on_status = on_status self.on_users = on_users self.on_file = on_file self.on_voice = on_voice self.on_system = on_system self.on_withdraw = on_withdraw self.on_announcement = on_announcement self.on_group_name = on_group_name self.on_kick = on_kick self.on_group_info = on_group_info self.on_need_approval = on_need_approval self.on_approval_granted = on_approval_granted self.on_approval_denied = on_approval_denied self.on_approval_request = on_approval_request self.on_user_key_issued = on_user_key_issued self.on_read = on_read self.account = '' self.auth_secret = None def _emit_status(self, text): if self.on_status: try: self.on_status(text) except Exception: pass def connect(self, host, port, username, nickname=None, account=None, auth_secret=None, user_key=None, user_key_enc=None): try: self.sock = socket.create_connection((host, port), timeout=10) self.sock.settimeout(None) self.username = username self.account = account or username self.auth_secret = auth_secret self.user_key = user_key self.user_key_enc = user_key_enc nick = nickname or username acc = account or username join_payload = {'type': 'join', 'username': nick, 'nickname': nick, 'account': acc} if auth_secret: join_payload['auth'] = hmac.new(auth_secret.encode('utf-8'), acc.encode('utf-8'), hashlib.sha256).hexdigest() if user_key: join_payload['user_auth'] = hmac.new( user_key.encode('utf-8'), ('JOIN:' + acc).encode('utf-8'), hashlib.sha256).hexdigest() send_msg(self.sock, join_payload) self.running = True self.connected = True threading.Thread(target=self._recv_loop, daemon=True).start() return True except Exception as e: self._emit_status(f"❌ 连接失败: {e}") return False def _sign(self, payload): """为消息附加账户身份 + 每用户 HMAC 签名 (服务器校验, 防冒充)""" payload['account'] = self.account or self.username secret = self.user_key or self.auth_secret if secret: canonical = json.dumps( {k: v for k, v in payload.items() if k not in ('sig', 'from', 'ts')}, sort_keys=True, ensure_ascii=False) payload['sig'] = hmac.new(secret.encode('utf-8'), canonical.encode('utf-8'), hashlib.sha256).hexdigest() return payload def _recv_loop(self): while self.running: try: msg = recv_msg(self.sock) if msg is None: break self._dispatch(msg) except Exception: break self.connected = False if self.running: self._emit_status("🔌 连接已断开") if self.on_system: try: self.on_system("🔌 与服务器的连接已断开") except Exception: pass def _dispatch(self, msg): t = msg.get('type') try: if t == 'chat': if self.on_message: self.on_message(msg.get('from', '?'), msg.get('text', ''), msg.get('mid', '')) elif t == 'system': if self.on_system: self.on_system(msg.get('text', '')) elif t == 'users': if self.on_users: self.on_users(msg.get('users', [])) elif t == 'file': if self.on_file: self.on_file(msg) elif t == 'voice': if self.on_voice: self.on_voice(msg) elif t == 'withdraw': if self.on_withdraw: self.on_withdraw(msg.get('mid', '')) elif t == 'read': if self.on_read: self.on_read(msg.get('mid', '')) elif t == 'announcement': if self.on_announcement: self.on_announcement(msg.get('text', '')) elif t == 'group_name': if self.on_group_name: self.on_group_name(msg.get('name', '')) elif t == 'group_info': if self.on_group_info: self.on_group_info(msg.get('group_name', ''), msg.get('announcement', '')) elif t == 'kick': if self.on_kick: self.on_kick(msg.get('reason', '你已被移出群')) elif t == 'need_approval': if self.on_need_approval: self.on_need_approval(msg.get('reason', '等待群主同意')) elif t == 'approval_granted': if self.on_approval_granted: self.on_approval_granted() elif t == 'approval_denied': if self.on_approval_denied: self.on_approval_denied(msg.get('reason', '群主拒绝你加入')) elif t == 'approval_request': if self.on_approval_request: self.on_approval_request(msg.get('account', ''), msg.get('nickname', '')) elif t == 'user_key_issued': try: if self.user_key_enc: uk = stream_xor(base64.b64decode(msg.get('data', '')), bytes.fromhex(self.user_key_enc)).decode('utf-8') self.user_key = uk if self.on_user_key_issued: self.on_user_key_issued(uk) except Exception: pass except Exception: pass # ---- 发送 ---- def send_chat(self, text, mid=None): if not self.connected: return False try: payload = {'type': 'chat', 'text': text, 'ts': int(time.time())} if mid: payload['mid'] = mid send_msg(self.sock, self._sign(payload)) return True except Exception as e: self._emit_status(f"❌ 发送失败: {e}") return False def send_file(self, filename, data_bytes, mid=None, mime='file'): if not self.connected: return False try: payload = {'type': 'file', 'filename': filename, 'size': len(data_bytes), 'data': base64.b64encode(data_bytes).decode('ascii'), 'mime': mime, 'ts': int(time.time())} if mid: payload['mid'] = mid send_msg(self.sock, self._sign(payload)) return True except Exception as e: self._emit_status(f"❌ 发送文件失败: {e}") return False def send_voice(self, wav_bytes, mid=None): if not self.connected: return False try: payload = {'type': 'voice', 'data': base64.b64encode(wav_bytes).decode('ascii'), 'size': len(wav_bytes), 'ts': int(time.time())} if mid: payload['mid'] = mid send_msg(self.sock, self._sign(payload)) return True except Exception as e: self._emit_status(f"❌ 发送语音失败: {e}") return False def send_withdraw(self, mid): if not self.connected: return False try: send_msg(self.sock, self._sign({'type': 'withdraw', 'mid': mid})) return True except Exception: return False def send_rename(self, new_name): if not self.connected: return False try: send_msg(self.sock, self._sign({'type': 'rename', 'new_name': new_name})) return True except Exception: return False def send_read(self, mid): """发送已读回执""" if not self.connected: return False try: send_msg(self.sock, self._sign({'type': 'read', 'mid': mid})) return True except Exception: return False def send_announcement(self, text): if not self.connected: return False try: send_msg(self.sock, self._sign({'type': 'set_announcement', 'text': text})) return True except Exception: return False def send_set_group_name(self, name): if not self.connected: return False try: send_msg(self.sock, self._sign({'type': 'set_group_name', 'name': name})) return True except Exception: return False def send_kick(self, account): if not self.connected: return False try: send_msg(self.sock, self._sign({'type': 'kick', 'target': account})) return True except Exception: return False def send_approval(self, account, decision='grant'): if not self.connected: return False try: send_msg(self.sock, self._sign({'type': 'approve', 'target': account, 'decision': decision})) return True except Exception: return False def disconnect(self): self.running = False self.connected = False if self.sock: try: self.sock.close() except Exception: pass self.sock = None # ==================== 中央服务器连接 (解决 NAT) ==================== class ServerConnection: """连接到中央服务器: 认证 + 群管理 + 消息中继 (密钥永不上传)""" def __init__(self, on_status=None, on_groups=None, on_message=None, on_members=None, on_system=None, on_group_name=None, on_announcement=None, on_group_info=None): self.sock = None self.running = False self.connected = False self.token = '' self.username = '' self.addr = '' self.on_status = on_status self.on_groups = on_groups self.on_message = on_message self.on_members = on_members self.on_system = on_system self.on_group_name = on_group_name self.on_announcement = on_announcement self.on_group_info = on_group_info def _emit(self, text): if self.on_status: try: self.on_status(text) except Exception: pass def connect(self, host, port): try: self.sock = socket.create_connection((host, port), timeout=10) self.sock.settimeout(None) self.addr = f"{host}:{port}" self.connected = True return True except Exception as e: self._emit(f"❌ 连接中央服务器失败: {e}") return False def register(self, username, password): send_msg(self.sock, {'type': 'register', 'username': username, 'password': password}) return self._wait_login() def login(self, username, password): send_msg(self.sock, {'type': 'login', 'username': username, 'password': password}) return self._wait_login() def _wait_login(self): try: self.sock.settimeout(6) msg = recv_msg(self.sock) except Exception: return False, '服务器无响应' finally: try: self.sock.settimeout(None) except Exception: pass if msg and msg.get('type') == 'login_ok': self.token = msg.get('token', '') self.username = msg.get('username', '') self.running = True threading.Thread(target=self._recv_loop, daemon=True).start() return True, 'ok' if msg and msg.get('type') == 'error': return False, msg.get('msg', '认证失败') return False, '服务器返回异常' def _recv_loop(self): while self.running: try: msg = recv_msg(self.sock) if msg is None: break self._dispatch(msg) except Exception: break self.connected = False if self.running: self._emit("🔌 与中央服务器断开") def _dispatch(self, msg): t = msg.get('type') try: if t == 'groups': if self.on_groups: self.on_groups(msg.get('groups', [])) elif t == 'group_message': if self.on_message: self.on_message(msg.get('group_id', ''), msg.get('from', '?'), msg.get('payload')) elif t == 'members': if self.on_members: self.on_members(msg.get('group_id', ''), msg.get('members', [])) elif t == 'system': if self.on_system: self.on_system(msg.get('group_id', ''), msg.get('text', '')) elif t == 'group_name': if self.on_group_name: self.on_group_name(msg.get('group_id', ''), msg.get('name', '')) elif t == 'announcement': if self.on_announcement: self.on_announcement(msg.get('group_id', ''), msg.get('text', '')) elif t == 'group_info': if self.on_group_info: self.on_group_info(msg.get('group_id', ''), msg.get('name', ''), msg.get('announcement', '')) except Exception: pass def create_group(self, group_id, name): send_msg(self.sock, {'type': 'create_group', 'group_id': group_id, 'name': name}) def list_groups(self): send_msg(self.sock, {'type': 'list_groups'}) def join_group(self, group_id): send_msg(self.sock, {'type': 'join_group', 'group_id': group_id}) def leave_group(self, group_id): send_msg(self.sock, {'type': 'leave_group', 'group_id': group_id}) def set_group_name(self, group_id, name): send_msg(self.sock, {'type': 'set_group_name', 'group_id': group_id, 'name': name}) def set_announcement(self, group_id, text): send_msg(self.sock, {'type': 'set_announcement', 'group_id': group_id, 'text': text}) def group_members(self, group_id): send_msg(self.sock, {'type': 'group_members', 'group_id': group_id}) def send_message(self, group_id, payload): if not self.connected: return False try: send_msg(self.sock, {'type': 'group_message', 'group_id': group_id, 'payload': payload}) return True except Exception: return False def disconnect(self): self.running = False self.connected = False if self.sock: try: self.sock.close() except Exception: pass self.sock = None # ==================== 会话文件 (记住登录) ==================== def save_session(token): try: with open(SESSION_FILE, 'w', encoding='utf-8') as f: f.write(token) except Exception: pass def load_session(): try: with open(SESSION_FILE, 'r', encoding='utf-8') as f: return f.read().strip() except Exception: return '' def clear_session(): try: if os.path.exists(SESSION_FILE): os.remove(SESSION_FILE) except Exception: pass # ==================== 服务器账号记住我 + 群本地缓存 ==================== SERVER_CRED_FILE = os.path.join(DATA_DIR, 'server_cred.dat') SERVER_GROUPS_CACHE = os.path.join(DATA_DIR, 'server_groups_cache.json') def _cred_key(pass_hash): """用本地账号密码哈希派生密钥 (记住服务器账号用)""" return hashlib.sha256(pass_hash.encode('utf-8') + b'|SERVER_CRED').digest() def save_server_cred(pass_hash, addr, username, password): """记住服务器账号: 加密存储 (密钥=本地账号密码哈希派生)""" try: key = _cred_key(pass_hash) nonce = os.urandom(16) k = hashlib.sha256(key + nonce).digest() ct = stream_xor(f"{addr}\n{username}\n{password}".encode('utf-8'), k) h = hmac.new(key, nonce + ct, hashlib.sha256).hexdigest() blob = base64.b64encode(b'CRED' + nonce + h.encode('ascii') + ct).decode('ascii') with open(SERVER_CRED_FILE, 'w', encoding='utf-8') as f: f.write(blob) return True except Exception: return False def load_server_cred(pass_hash): """读取记住的服务器账号; 密钥不符返回 None""" try: with open(SERVER_CRED_FILE, 'r', encoding='utf-8') as f: b64 = f.read().strip() key = _cred_key(pass_hash) blob = base64.b64decode(b64) if not blob.startswith(b'CRED'): return None nonce = blob[4:20] h = blob[20:84] ct = blob[84:] if hmac.new(key, nonce + ct, hashlib.sha256).hexdigest() != h.decode('ascii'): return None k = hashlib.sha256(key + nonce).digest() text = stream_xor(ct, k).decode('utf-8') addr, username, password = text.split('\n', 2) return (addr, username, password) except Exception: return None def save_server_groups_cache(server_addr, groups): """本地缓存服务器群列表 (离线也能显示)""" try: data = {} if os.path.exists(SERVER_GROUPS_CACHE): with open(SERVER_GROUPS_CACHE, 'r', encoding='utf-8') as f: data = json.load(f) data[server_addr] = groups with open(SERVER_GROUPS_CACHE, 'w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False) except Exception: pass def load_server_groups_cache(server_addr): try: if os.path.exists(SERVER_GROUPS_CACHE): with open(SERVER_GROUPS_CACHE, 'r', encoding='utf-8') as f: data = json.load(f) return data.get(server_addr, []) except Exception: pass return [] import warnings warnings.filterwarnings('ignore', message='sipPyTypeDict.*') # PyQt5 from PyQt5 import QtCore, QtGui, QtWidgets # 复用后端 (dvs_enc.py) from dvs_enc import ( AccountDB, HistoryDB, ServerDB, SecureSession, ChatClient, ChatServer, ServerConnection, VoiceManager, Message, gen_mid, save_media, delete_media, clear_media_cache, save_session, load_session, clear_session, MEDIA_DIR ) APP_NAME = "🔐 混沌加密聊天" DOWNLOAD_DIR = os.path.join(os.path.expanduser('~'), 'Downloads', 'ChaosChat') try: os.makedirs(DOWNLOAD_DIR, exist_ok=True) except Exception: pass # ==================== 现代化样式 (QSS) ==================== QSS = """ QMainWindow, QDialog { background: #ffffff; } QLabel { color: #2c3e50; } QListWidget { background: #ffffff; border: 1px solid #e0e4ea; border-radius: 8px; padding: 4px; font-size: 13px; } QListWidget::item { padding: 6px; border-radius: 6px; margin: 2px; } QListWidget::item:selected { background: #dbe7ff; color: #1a73e8; } QListWidget::item:hover { background: #f0f4ff; } QTextEdit, QLineEdit { background: #ffffff; border: 1px solid #e0e4ea; border-radius: 8px; padding: 6px; font-size: 13px; } QPushButton { background: #eef1f6; border: none; border-radius: 8px; padding: 7px 14px; font-size: 13px; color: #2c3e50; } QPushButton:hover { background: #e2e7f0; } QPushButton:disabled { background: #f0f0f0; color: #aaa; } QPushButton.primary { background: #4CAF50; color: white; } QPushButton.primary:hover { background: #43a047; } QPushButton.info { background: #2196F3; color: white; } QPushButton.info:hover { background: #1e88e5; } QPushButton.danger { background: #e74c3c; color: white; } QPushButton.danger:hover { background: #c0392b; } QToolBar { background: #ffffff; border-bottom: 1px solid #e0e4ea; spacing: 4px; padding: 4px; } QMenuBar { background: #ffffff; border-bottom: 1px solid #e0e4ea; } QMenuBar::item { padding: 6px 10px; border-radius: 6px; } QMenuBar::item:selected { background: #dbe7ff; } QMenu { background: #ffffff; border: 1px solid #e0e4ea; border-radius: 8px; padding: 6px; } QMenu::item { padding: 6px 24px; border-radius: 6px; } QMenu::item:selected { background: #dbe7ff; color: #1a73e8; } QScrollBar:vertical { background: transparent; width: 8px; } QScrollBar::handle:vertical { background: #c5cbd6; border-radius: 4px; min-height: 30px; } QScrollBar::add-line, QScrollBar::sub-line { height: 0; } QStatusBar { background: #ffffff; border-top: 1px solid #e0e4ea; } """ # ==================== 消息项组件 ==================== class MessageItem(QtWidgets.QWidget): """单条消息: 我的靠左, 别人靠右; 整条可选中, 右键菜单""" def __init__(self, msg, actions, parent=None): super().__init__(parent) self.msg = msg self.mid = msg.mid self.actions = actions self._selected = False self._notify_select = None self._status_lab = None self.setCursor(QtCore.Qt.PointingHandCursor) # 关键: 自定义 QWidget 必须开 WA_StyledBackground, 否则 QSS 背景不绘制 self.setAttribute(QtCore.Qt.WA_StyledBackground, True) self.setStyleSheet("MessageItem{background:#ffffff; border-radius:12px;}") lay = QtWidgets.QVBoxLayout(self) lay.setContentsMargins(10, 6, 10, 6) lay.setSpacing(3) # 头部: 发送者 + 时间 (我的靠左, 别人靠右; 我的显示已读/未读) head_row = QtWidgets.QHBoxLayout() head = QtWidgets.QLabel(f"👤 {msg.sender} {datetime.fromtimestamp(msg.timestamp).strftime('%H:%M:%S')}") head.setStyleSheet("font-weight:bold; color:%s;" % ('#1a73e8' if msg.is_mine else '#7a5af8')) if msg.is_mine: head_row.addWidget(head) head_row.addStretch() self._status_lab = QtWidgets.QLabel("已读" if msg.read else "未读") self._status_lab.setStyleSheet("color:#999; font-size:11px;") head_row.addWidget(self._status_lab) else: head_row.addStretch() head_row.addWidget(head) lay.addLayout(head_row) # 内容 if msg.withdrawn: lab = QtWidgets.QLabel("🚫 此消息已撤回") lab.setStyleSheet("color:#999; font-style:italic;") self._align(lay, lab) elif msg.type == 'text': lab = QtWidgets.QLabel(msg.content) lab.setWordWrap(True) lab.setMaximumWidth(560) lab.setStyleSheet("background:#f0f2f5; border-radius:14px; padding:10px 14px;") lab.setSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Preferred) self._align(lay, lab) elif msg.type == 'image': self._add_image(lay) elif msg.type in ('voice', 'audio'): self._add_audio(lay) elif msg.type == 'file': self._add_file(lay) else: lab = QtWidgets.QLabel(str(msg.content)) lab.setStyleSheet("color:#666;") self._align(lay, lab) # 关键: 按钮不装过滤器且不透明(保证可点击); 其他子控件(文字/气泡)装过滤器 → 点击选中 self.installEventFilter(self) for ch in self.findChildren(QtWidgets.QWidget): if not isinstance(ch, QtWidgets.QPushButton): ch.installEventFilter(self) def _align(self, lay, w): """我的消息靠左, 别人的靠右""" if self.msg.is_mine: lay.addWidget(w, 0, QtCore.Qt.AlignLeft) else: lay.addWidget(w, 0, QtCore.Qt.AlignRight) def _align_row(self, row, bubble): if self.msg.is_mine: row.addWidget(bubble) row.addStretch() else: row.addStretch() row.addWidget(bubble) def mousePressEvent(self, event): # 点击消息自身背景 → 选中 if event.button() == QtCore.Qt.LeftButton and self._notify_select: self._notify_select() super().mousePressEvent(event) def eventFilter(self, obj, event): # 点击文字/气泡等子控件(按钮除外) → 选中本消息; 按钮保持自身功能 if event.type() == QtCore.QEvent.MouseButtonPress and event.button() == QtCore.Qt.LeftButton: if not isinstance(obj, QtWidgets.QPushButton) and self._notify_select: self._notify_select() return False def set_selected(self, sel): self._selected = sel if sel: self.setStyleSheet("MessageItem{background:#9cc6ff; border-left:6px solid #1a73e8; border-radius:12px;}") else: self.setStyleSheet("MessageItem{background:#ffffff; border-radius:12px;}") def set_read_status(self, read): if self._status_lab: self._status_lab.setText("已读" if read else "未读") def _act(self, key, *args): fn = self.actions.get(key) if fn: try: fn(*args) except Exception as e: print(f" [ACT] 操作 {key} 失败: {e}") def _add_file(self, lay): c = self.msg.content if isinstance(self.msg.content, dict) else {} fname = c.get('filename', '文件') size = c.get('size', 0) ext = c.get('ext', os.path.splitext(fname)[1]) icon = file_icon(ext) bubble = QtWidgets.QFrame() bubble.setStyleSheet("QFrame{background:#e8f0fe; border-radius:14px;}") blay = QtWidgets.QHBoxLayout(bubble) blay.setContentsMargins(16, 10, 10, 10) blay.setSpacing(10) lab = QtWidgets.QLabel(f"{icon} {fname} ({size // 1024} KB)") lab.setStyleSheet("background:transparent; color:#202124; font-size:13px;") lab.setMaximumWidth(360) blay.addWidget(lab) dl = QtWidgets.QPushButton("💾 下载") dl.setStyleSheet("background:#d3e0fb; color:#1a73e8; border-radius:8px; padding:5px 12px;") dl.clicked.connect(lambda: self._act('on_download', self.mid)) blay.addWidget(dl) row = QtWidgets.QHBoxLayout() self._align_row(row, bubble) lay.addLayout(row) def _add_audio(self, lay): c = self.msg.content if isinstance(self.msg.content, dict) else {} fname = c.get('filename', '') label = "🔊 语音" if self.msg.type == 'voice' else f"🎵 音频 {fname}" size = c.get('size', 0) bubble = QtWidgets.QFrame() bubble.setStyleSheet("QFrame{background:#efe9ff; border-radius:14px;}") blay = QtWidgets.QHBoxLayout(bubble) blay.setContentsMargins(12, 10, 10, 10) blay.setSpacing(10) play = QtWidgets.QPushButton("▶ 播放") play.setStyleSheet("background:#7a5af8; color:#fff; border-radius:10px; padding:7px 16px;") play.clicked.connect(lambda: self._act('on_play', self.mid)) blay.addWidget(play) lab = QtWidgets.QLabel(f"{label} ({size // 1024} KB)") lab.setStyleSheet("background:transparent; color:#5a4a9a; font-size:13px;") blay.addWidget(lab) dl = QtWidgets.QPushButton("💾") dl.setStyleSheet("background:#ddd4f7; color:#5a4a9a; border-radius:8px; padding:5px 10px;") dl.clicked.connect(lambda: self._act('on_download', self.mid)) blay.addWidget(dl) row = QtWidgets.QHBoxLayout() self._align_row(row, bubble) lay.addLayout(row) def _add_image(self, lay): c = self.msg.content if isinstance(self.msg.content, dict) else {} data = self.msg.get_data_bytes() bubble = QtWidgets.QFrame() bubble.setStyleSheet("QFrame{background:#ffffff; border-radius:14px; border:1px solid #e0e4ea;}") blay = QtWidgets.QHBoxLayout(bubble) blay.setContentsMargins(8, 8, 8, 8) blay.setSpacing(8) if data: try: from PIL import Image, ImageTk img = Image.open(io.BytesIO(data)) img.thumbnail((220, 220)) img = img.convert("RGBA") qimg = QtGui.QImage(img.tobytes(), img.width, img.height, QtGui.QImage.Format_RGBA8888) pix = QtGui.QPixmap.fromImage(qimg) lab = QtWidgets.QLabel() lab.setPixmap(pix) lab.setCursor(QtCore.Qt.PointingHandCursor) lab.mousePressEvent = lambda e: self._act('on_preview', self.mid) blay.addWidget(lab) except Exception: lab = QtWidgets.QLabel("🖼️ 图片(无法预览)") blay.addWidget(lab) else: lab = QtWidgets.QLabel("🖼️ 图片(文件已清理)") blay.addWidget(lab) dl = QtWidgets.QPushButton("💾 下载") dl.setStyleSheet("background:#eef1f6; color:#2c3e50; border-radius:8px; padding:5px 12px;") dl.clicked.connect(lambda: self._act('on_download', self.mid)) blay.addWidget(dl) row = QtWidgets.QHBoxLayout() self._align_row(row, bubble) lay.addLayout(row) class MessageList(QtWidgets.QScrollArea): """自适应消息列表 (QScrollArea + VBox): 每条消息按内容自动调高""" def __init__(self, on_menu=None, on_selected=None, parent=None): super().__init__(parent) self.setWidgetResizable(True) self.setFrameShape(QtWidgets.QFrame.NoFrame) self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self._container = QtWidgets.QWidget() self._container.setStyleSheet("background:#ffffff;") self._layout = QtWidgets.QVBoxLayout(self._container) self._layout.setContentsMargins(6, 6, 6, 6) self._layout.setSpacing(6) self._layout.addStretch(1) # 消息从顶部排列, 底部弹性 self.setWidget(self._container) self._items = {} # mid -> MessageItem self.selected_mid = None self._on_menu = on_menu self._on_selected = on_selected self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) self.customContextMenuRequested.connect(self._on_right_click) # 点击空白处 → 取消选中 self._container.installEventFilter(self) self.viewport().installEventFilter(self) self.installEventFilter(self) def eventFilter(self, obj, event): """点击消息区空白处 → 自动取消选中""" if event.type() == QtCore.QEvent.MouseButtonPress and event.button() == QtCore.Qt.LeftButton: if obj in (self._container, self.viewport(), self): self._deselect() return False def _deselect(self): self.selected_mid = None for w in self._items.values(): w.set_selected(False) if self._on_selected: self._on_selected(None) def _select(self, mid): self.selected_mid = mid for m, w in self._items.items(): w.set_selected(m == mid) if self._on_selected: self._on_selected(mid) def _on_right_click(self, pos): item = self._message_at(pos) if not item: self._deselect() return self._select(item.mid) if self._on_menu: self._on_menu(item.mid, self.mapToGlobal(pos)) def _message_at(self, pos): global_pos = self.mapToGlobal(pos) w = self._container.childAt(self._container.mapFromGlobal(global_pos)) while w is not None: if isinstance(w, MessageItem): return w w = w.parentWidget() return None def add_message(self, msg, actions): wid = MessageItem(msg, actions) wid._notify_select = lambda m=msg.mid: self._select(m) wid.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) self._layout.insertWidget(self._layout.count() - 1, wid) # 插到 stretch 之前 self._items[msg.mid] = wid # 等宽度确定后再滚到底部 QtCore.QTimer.singleShot(0, self._scroll_bottom) return wid def update_message(self, mid, msg): """就地更新某条消息 (已读/未读状态)""" wid = self._items.get(mid) if wid: wid.set_read_status(msg.read) def _scroll_bottom(self): bar = self.verticalScrollBar() bar.setValue(bar.maximum()) def remove_message(self, mid): wid = self._items.pop(mid, None) if wid: self._layout.removeWidget(wid) wid.deleteLater() if self.selected_mid == mid: self.selected_mid = None def clear_all(self): for w in self._items.values(): self._layout.removeWidget(w) w.deleteLater() self._items.clear() self.selected_mid = None # ==================== 登录对话框 ==================== class LoginDialog(QtWidgets.QDialog): """本地账号 注册/登录""" def __init__(self, db, parent=None): super().__init__(parent) self.db = db self.result = None self.mode = 'login' self.setWindowTitle(f"{APP_NAME} - 登录") self.setFixedSize(380, 420) self._build() def _build(self): lay = QtWidgets.QVBoxLayout(self) lay.setSpacing(10) title = QtWidgets.QLabel("🔐 混沌加密聊天") title.setAlignment(QtCore.Qt.AlignCenter) title.setStyleSheet("font-size:20px; font-weight:bold; color:#1a73e8;") lay.addWidget(title) self.mode_lab = QtWidgets.QLabel("本地账号登录") self.mode_lab.setAlignment(QtCore.Qt.AlignCenter) self.mode_lab.setStyleSheet("font-size:13px; color:#555;") lay.addWidget(self.mode_lab) self.u = QtWidgets.QLineEdit() self.u.setPlaceholderText("账户名") lay.addWidget(self.u) self.p = QtWidgets.QLineEdit() self.p.setPlaceholderText("密码") self.p.setEchoMode(QtWidgets.QLineEdit.Password) lay.addWidget(self.p) self.p2 = QtWidgets.QLineEdit() self.p2.setPlaceholderText("确认密码") self.p2.setEchoMode(QtWidgets.QLineEdit.Password) self.p2.setVisible(False) lay.addWidget(self.p2) self.msg = QtWidgets.QLabel("") self.msg.setStyleSheet("color:#e74c3c;") lay.addWidget(self.msg) row = QtWidgets.QHBoxLayout() self.btn_main = QtWidgets.QPushButton("登 录") self.btn_main.setProperty("class", "primary") self.btn_main.clicked.connect(self._submit) row.addWidget(self.btn_main) self.btn_switch = QtWidgets.QPushButton("没有账号?注册") self.btn_switch.clicked.connect(self._toggle) row.addWidget(self.btn_switch) lay.addLayout(row) lay.addStretch() def _toggle(self): if self.mode == 'login': self.mode = 'register' self.mode_lab.setText("注册本地账号") self.btn_main.setText("注 册") self.btn_switch.setText("已有账号?登录") self.p2.setVisible(True) else: self.mode = 'login' self.mode_lab.setText("本地账号登录") self.btn_main.setText("登 录") self.btn_switch.setText("没有账号?注册") self.p2.setVisible(False) self.msg.setText("") def _submit(self): user = self.u.text().strip() pwd = self.p.text() if not user or not pwd: self.msg.setText("请输入账户名和密码") return if self.mode == 'login': row, err = self.db.login(user, pwd) if row is None: self.msg.setText(err) return self.result = row self.accept() else: if len(pwd) < 6: self.msg.setText("密码至少 6 位") return if pwd != self.p2.text(): self.msg.setText("两次密码不一致") return ok, err = self.db.register(user, pwd) if not ok: self.msg.setText(err) return row, _ = self.db.login(user, pwd) self.result = row self.accept() # ==================== 主窗口 ==================== class MainWindow(QtWidgets.QMainWindow): def __init__(self, account): super().__init__() self.account = dict(account) if account else {'id': 1, 'username': 'user', 'nickname': '用户'} self.account_db = AccountDB() self.history = HistoryDB() self.server_db = ServerDB() self.voice = VoiceManager(on_status=self._on_status) self.groups = {} # gid -> dict self.order = [] self.active_gid = None self.selected_mid = None self._gid_counter = 0 self.server_conn = None self.server_addr = '' self.server_username = '' self._ui_queue = [] self._relogin = False self.setWindowTitle(f"{APP_NAME} - {self._my_nick()}") self.resize(1120, 720) self._build_ui() # UI 轮询 self._timer = QtCore.QTimer(self) self._timer.timeout.connect(self._poll_ui) self._timer.start(60) # 启动自动加入 (本地P2P群) QtCore.QTimer.singleShot(500, self._start_auto_join) # 服务器: 先显示本地缓存, 再记住我自动登录实时同步 QtCore.QTimer.singleShot(800, self._load_cached_server_groups) QtCore.QTimer.singleShot(1500, self._auto_connect_server) # ---------- 线程安全 ---------- def _post(self, fn): self._ui_queue.append(fn) def _poll_ui(self): if self._ui_queue: tasks = self._ui_queue[:] self._ui_queue = [] for fn in tasks: try: fn() except Exception: pass def _my_nick(self): return self.account.get('nickname') or self.account.get('username', '用户') def _on_status(self, text): self._post(lambda: self.statusBar().showMessage(text, 5000)) # ---------- 界面 ---------- def _build_ui(self): # 工具栏 tb = QtWidgets.QToolBar("主工具栏") tb.setMovable(False) self.addToolBar(tb) def btn(text, fn, cls=''): b = QtWidgets.QPushButton(text) if cls: b.setProperty("class", cls) b.clicked.connect(fn) tb.addWidget(b) return b btn("📡 创建群", self._create_group, 'primary') btn("📥 加入群", self._join_group, 'info') tb.addSeparator() btn("☁️ 服务器", self._connect_server) btn("☁️ 建群", self._create_server_group) btn("☁️ 加群", self._join_server_group) tb.addSeparator() btn("🔑 密钥", self._copy_active_key) btn("✏️ 改名", self._rename) btn("📢 公告", self._set_announcement) btn("🏷️ 群名", self._set_group_name) btn("🚫 撤回", self._withdraw_selected) btn("📤 分享", self._share_selected) # 菜单 mb = self.menuBar() gm = mb.addMenu("群") gm.addAction("📡 创建群", self._create_group) gm.addAction("📥 加入群", self._join_group) gm.addAction("❌ 离开该群", self._leave_active_group) sm = mb.addMenu("服务器") sm.addAction("☁️ 连接服务器", self._connect_server) sm.addAction("☁️ 服务器建群", self._create_server_group) sm.addAction("☁️ 服务器加群", self._join_server_group) mm = mb.addMenu("消息") mm.addAction("🚫 撤回所选", self._withdraw_selected) mm.addAction("📤 分享所选", self._share_selected) mm.addAction("💾 下载所选", self._download_selected) mm.addAction("📋 复制所选", self._copy_selected) um = mb.addMenu("工具") um.addAction("🗑️ 清理媒体缓存", self._clear_media_cache) um.addAction("📂 打开下载目录", self._open_download_dir) um.addAction("🔓 退出登录", self._logout) um.addAction("❌ 退出程序", self._quit) # 中央区域 central = QtWidgets.QWidget() self.setCentralWidget(central) root = QtWidgets.QHBoxLayout(central) root.setContentsMargins(8, 8, 8, 8) root.setSpacing(8) # 左: 群列表 left = QtWidgets.QVBoxLayout() left.addWidget(QtWidgets.QLabel("👥 群列表")) self.group_list = QtWidgets.QListWidget() self.group_list.setFixedWidth(240) self.group_list.itemClicked.connect(self._on_group_click) self.group_list.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) self.group_list.customContextMenuRequested.connect(self._on_group_menu) left.addWidget(self.group_list) left_w = QtWidgets.QWidget() left_w.setLayout(left) root.addWidget(left_w) # 中: 公告横幅 + 消息 + 输入 center = QtWidgets.QVBoxLayout() self.banner = QtWidgets.QFrame() self.banner.setStyleSheet("background:#fff3cd; border-radius:8px;") b_lay = QtWidgets.QHBoxLayout(self.banner) self.banner_label = QtWidgets.QLabel("") self.banner_label.setStyleSheet("color:#8a6d3b;") b_lay.addWidget(self.banner_label, 1) b_close = QtWidgets.QPushButton("✕ 关闭") b_close.setFixedWidth(60) b_close.clicked.connect(self._hide_banner) b_lay.addWidget(b_close) self.banner.setVisible(False) center.addWidget(self.banner) self.message_list = MessageList(on_menu=self._show_context_menu, on_selected=lambda mid: self._on_msg_selected(mid)) center.addWidget(self.message_list, 1) # 输入区: 多行输入 + 发送 + 图片/文件/语音按钮 in_area = QtWidgets.QVBoxLayout() in_row = QtWidgets.QHBoxLayout() self.input = QtWidgets.QTextEdit() self.input.setFixedHeight(56) self.input.setPlaceholderText("输入消息... (Ctrl+Enter 发送)") QtWidgets.QShortcut(QtGui.QKeySequence("Ctrl+Return"), self.input, activated=self._send_text) send = QtWidgets.QPushButton("发 送") send.setProperty("class", "primary") send.setFixedHeight(44) send.clicked.connect(self._send_text) in_row.addWidget(self.input, 1) in_row.addWidget(send) in_area.addLayout(in_row) btn_row = QtWidgets.QHBoxLayout() self.img_btn = QtWidgets.QPushButton("📷 图片") self.img_btn.clicked.connect(self._send_image) self.file_btn = QtWidgets.QPushButton("📁 文件") self.file_btn.clicked.connect(self._send_file) self.record_btn = QtWidgets.QPushButton("🎤 录音") self.record_btn.clicked.connect(self._toggle_record) self.play_btn = QtWidgets.QPushButton("▶ 播放") self.play_btn.clicked.connect(self._play_voice) for b in (self.img_btn, self.file_btn, self.record_btn, self.play_btn): b.setFixedHeight(28) btn_row.addWidget(b) btn_row.addStretch() in_area.addLayout(btn_row) center.addLayout(in_area) center_w = QtWidgets.QWidget() center_w.setLayout(center) root.addWidget(center_w, 1) # 右: 用户列表 right = QtWidgets.QVBoxLayout() right.addWidget(QtWidgets.QLabel("🟢 在线用户")) self.users_list = QtWidgets.QListWidget() self.users_list.setFixedWidth(180) self.users_list.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) self.users_list.customContextMenuRequested.connect(self._on_user_menu) right.addWidget(self.users_list) right_w = QtWidgets.QWidget() right_w.setLayout(right) root.addWidget(right_w) self.statusBar().showMessage("就绪") self._add_system(None, "📌 点击工具栏或菜单开始;消息可点击选中,右键有更多操作") # ---------- 公告横幅 ---------- def _show_banner(self, text): if not text: return self.banner_label.setText(f"📢 群公告: {text}") self.banner.setVisible(True) def _hide_banner(self): self.banner.setVisible(False) # ---------- 群管理 ---------- def _new_gid(self): self._gid_counter += 1 return f"g{self._gid_counter}" def _group_connected(self, group): if group.get('mode') == 'server': return bool(group.get('server_conn') and group['server_conn'].connected) return bool(group.get('client') and group['client'].connected) def _build_group(self, gid, host, port, nick, is_host, server, client, session): return { 'gid': gid, 'host': host, 'port': port, 'group_key': f"{host}:{port}", 'name': nick, 'my_username': nick, 'my_account': self.account.get('username', ''), 'is_host': is_host, 'server': server, 'client': client, 'session': session, 'users': [], 'messages': {}, 'order': [], 'announcement': '', 'group_name': '', 'mode': 'p2p', 'user_key': None } def _register_group(self, gid, group): if gid in self.groups: return for g in list(self.groups.values()): if g['group_key'] == group['group_key']: if group.get('client'): try: group['client'].disconnect() except Exception: pass if group.get('server'): try: group['server'].stop() except Exception: pass return self.groups[gid] = group self.order.append(gid) self._load_history(gid) self._refresh_groups() self._switch_group(gid) def _refresh_groups(self): self.group_list.clear() for i, gid in enumerate(self.order): g = self.groups[gid] tag = '[群主]' if g['is_host'] else '' prefix = '☁️' if g.get('mode') == 'server' else '🔗' status = '🟢' if self._group_connected(g) else '🔴' addr = f"{g['host']}:{g['port']}" u = g.get('unread', 0) badge = f" 🔴{u}" if u else "" item = QtWidgets.QListWidgetItem(f"{status}{prefix}{tag} {g['name']} {addr}{badge}") item.setData(QtCore.Qt.UserRole, gid) self.group_list.addItem(item) def _on_group_click(self, item): gid = item.data(QtCore.Qt.UserRole) if gid and gid != self.active_gid: self._switch_group(gid) def _switch_group(self, gid): self.active_gid = gid self.selected_mid = None group = self.groups.get(gid) if group: group['unread'] = 0 for mid, msg in list(group['messages'].items()): if not msg.is_mine and not msg.read: msg.read = True self._send_read(gid, mid) self._render_group(gid) self._refresh_groups() def _render_group(self, gid): self.message_list.clear_all() group = self.groups.get(gid) self._render_users(gid) if not group: return for mid in group['order']: msg = group['messages'][mid] self.message_list.add_message(msg, self._panel_actions()) if group.get('announcement'): self._show_banner(group['announcement']) else: self._hide_banner() def _render_users(self, gid): self.users_list.clear() group = self.groups.get(gid) if not group: return for u in group['users']: name = u.get('name', '?') mark = ' (我)' if name == group['my_username'] else '' self.users_list.addItem(f"👤 {name}{mark}") def _panel_actions(self): return { 'on_download': self._on_download, 'on_play': self._on_play, 'on_preview': self._on_preview, } def _add_message(self, gid, mid, sender, mtype, content, ts, is_mine=False, persist=True): group = self.groups.get(gid) if not group: return if mid and mid in group['messages']: return msg = Message(mid, gid, sender, mtype, content, ts, is_mine) group['messages'][mid] = msg group['order'].append(mid) # 已读/未读: 他人消息, 当前群立即已读+回执; 非当前群计未读 if not is_mine and mtype != 'system': if gid == self.active_gid: msg.read = True self._send_read(gid, mid) else: group['unread'] = group.get('unread', 0) + 1 self._refresh_groups() if persist: try: self.history.save(self.account['id'], group['group_key'], msg) except Exception: pass if gid == self.active_gid: self.message_list.add_message(msg, self._panel_actions()) def _add_local_message(self, gid, mid, mtype, content): group = self.groups.get(gid) if group: self._add_message(gid, mid, group['my_username'], mtype, content, time.time(), is_mine=True) def _add_system(self, gid, text): if gid is None: if self.active_gid: gid = self.active_gid else: return self._add_message(gid, gen_mid(), '系统', 'system', text, time.time(), persist=False) def _delete_message(self, gid, mid): group = self.groups.get(gid) if not group: return False msg = group['messages'].pop(mid, None) if not msg: return False if isinstance(msg.content, dict): delete_media(msg.content.get('path')) try: self.history.delete_by_mid(self.account['id'], group['group_key'], mid) except Exception: pass if mid in group['order']: group['order'].remove(mid) if gid == self.active_gid: self.message_list.remove_message(mid) return True def _load_history(self, gid): group = self.groups.get(gid) if not group: return try: rows = self.history.load(self.account['id'], group['group_key']) except Exception: rows = [] for row in rows: mid = row['mid'] if not mid or mid in group['messages']: continue content = HistoryDB.deserialize_content(row['mtype'], row['content']) msg = Message(mid, gid, row['sender'], row['mtype'], content, row['ts'], bool(row['is_mine'])) msg.withdrawn = bool(row['withdrawn']) group['messages'][mid] = msg group['order'].append(mid) # ---------- 客户端工厂 ---------- def _make_client(self, gid): return ChatClient( on_message=lambda s, t, m, g=gid: self._post(lambda: self._on_chat(g, s, t, m)), on_status=lambda t, g=gid: self._post(lambda: self._on_status(t)), on_users=lambda u, g=gid: self._post(lambda: self._on_users(g, u)), on_file=lambda m, g=gid: self._on_file(g, m), on_voice=lambda m, g=gid: self._on_voice(g, m), on_system=lambda t, g=gid: self._post(lambda: self._add_system(g, t)), on_withdraw=lambda m, g=gid: self._post(lambda: self._on_withdraw(g, m)), on_announcement=lambda t, g=gid: self._post(lambda: self._on_announcement(g, t)), on_group_name=lambda n, g=gid: self._post(lambda: self._on_group_name(g, n)), on_kick=lambda r, g=gid: self._post(lambda: self._on_kick(g, r)), on_group_info=lambda gn, an, g=gid: self._post(lambda: self._on_group_info(g, gn, an)), on_need_approval=lambda r, g=gid: self._post(lambda: self._add_system(g, f"⏳ {r}")), on_approval_granted=lambda g=gid: self._post(lambda: self._add_system(g, "✅ 群主已同意,你已加入")), on_approval_denied=lambda r, g=gid: self._post(lambda: self._on_approval_denied(g, r)), on_approval_request=lambda a, n, g=gid: self._post(lambda: self._on_approval_request(g, a, n)), on_user_key_issued=lambda uk, g=gid: self._post(lambda: self._on_user_key_issued(g, uk)), on_read=lambda m, g=gid: self._post( lambda: self._on_read(g, m)), ) # ---------- P2P 建群/加群 ---------- def _create_group(self): from PyQt5.QtWidgets import QInputDialog port, ok = QInputDialog.getInt(self, "端口", "请输入端口号:", value=8888) if not ok: return gid = self._new_gid() session = SecureSession(); session.generate() group_key = f"127.0.0.1:{port}" server = ChatServer(on_status=lambda t, g=gid: self._post(lambda: self._on_status(t)), server_key=group_key, server_db=self.server_db, server_secret=session.session_key) if not server.start(port): self._on_status("❌ 服务器启动失败(端口被占用?)") return client = self._make_client(gid) saved_uk = self._get_saved_user_key(group_key) if not client.connect('127.0.0.1', port, self._my_nick(), nickname=self._my_nick(), account=self.account.get('username', ''), auth_secret=session.auth_secret(), user_key=saved_uk, user_key_enc=session.user_key_enc()): server.stop() return group = self._build_group(gid, '127.0.0.1', port, self._my_nick(), True, server, client, session) group['user_key'] = saved_uk self._register_group(gid, group) self._save_group(gid) self._add_system(gid, "✅ 群已创建(端到端),你是群主") self._show_key_dialog(gid) def _join_group(self): from PyQt5.QtWidgets import QInputDialog host, ok1 = QInputDialog.getText(self, "服务器地址", "服务器IP:", text="127.0.0.1") if not ok1: return port, ok2 = QInputDialog.getInt(self, "端口", "端口号:", value=8888) if not ok2: return key, ok3 = QInputDialog.getText(self, "密钥", "会话密钥(创建者分享):") if not ok3 or not key: return session = SecureSession() if not session.import_key(key): self._on_status("❌ 密钥无效") return gid = self._new_gid() client = self._make_client(gid) saved_uk = self._get_saved_user_key(f"{host}:{port}") if not client.connect(host, port, self._my_nick(), nickname=self._my_nick(), account=self.account.get('username', ''), auth_secret=session.auth_secret(), user_key=saved_uk, user_key_enc=session.user_key_enc()): return group = self._build_group(gid, host, port, self._my_nick(), False, None, client, session) group['user_key'] = saved_uk self._register_group(gid, group) self._save_group(gid) def _save_group(self, gid): group = self.groups.get(gid) if not group: return try: self.account_db.save_group(self.account['id'], group['group_key'], group['host'], group['port'], group['session'].export_key(), self._my_nick(), group['is_host'], group.get('user_key', '')) except Exception: pass def _get_saved_user_key(self, group_key): try: for r in self.account_db.load_groups(self.account['id']): if r['group_key'] == group_key: return r['user_key'] or None except Exception: pass return None # ---------- 发送辅助 ---------- def _send_chat_to(self, group, enc, mid): if group.get('mode') == 'server': return group['server_conn'].send_message(group['group_id'], {'kind': 'chat', 'text': enc, 'mid': mid, 'ts': int(time.time())}) return group['client'].send_chat(enc, mid) def _send_file_to(self, group, fname, enc_bytes, mid, mime): if group.get('mode') == 'server': return group['server_conn'].send_message(group['group_id'], { 'kind': 'file', 'filename': fname, 'size': len(enc_bytes), 'data': base64.b64encode(enc_bytes).decode('ascii'), 'mime': mime, 'mid': mid, 'ts': int(time.time())}) return group['client'].send_file(fname, enc_bytes, mid, mime=mime) def _send_voice_to(self, group, enc_bytes, mid): if group.get('mode') == 'server': return group['server_conn'].send_message(group['group_id'], { 'kind': 'voice', 'data': base64.b64encode(enc_bytes).decode('ascii'), 'size': len(enc_bytes), 'mid': mid, 'ts': int(time.time())}) return group['client'].send_voice(enc_bytes, mid) def _send_withdraw_to(self, group, mid): if group.get('mode') == 'server': return group['server_conn'].send_message(group['group_id'], {'kind': 'withdraw', 'mid': mid}) return group['client'].send_withdraw(mid) # ---------- 发送 ---------- def _send_text(self): gid, group = self._current_group() if group is None: self._on_status("请先创建或加入一个群") return if not self._group_connected(group): self._on_status("该群未连接") return if not group['session'].has_key(): self._on_status("该群没有会话密钥") return text = self.input.toPlainText().strip() if not text: return try: mid = gen_mid() enc = group['session'].encrypt(text) if self._send_chat_to(group, enc, mid): self._add_local_message(gid, mid, 'text', text) self.input.clear() except Exception as e: self._add_system(gid, f"❌ 加密失败: {e}") def _send_image(self): gid, group = self._current_group() if group is None or not self._group_connected(group): return path, _ = QtWidgets.QFileDialog.getOpenFileName(self, "选择图片", "", "图片 (*.png *.jpg *.jpeg *.gif *.bmp *.webp)") if not path: return try: with open(path, 'rb') as f: data = f.read() fname = os.path.basename(path) ext = os.path.splitext(fname)[1].lower() mid = gen_mid() enc = group['session'].encrypt_bytes(data) media_path = save_media(data, ext) content = {'filename': fname, 'size': len(data), 'path': media_path, 'ext': ext} if self._send_file_to(group, fname, enc.encode('utf-8'), mid, 'image'): self._add_local_message(gid, mid, 'image', content) except Exception as e: self._add_system(gid, f"❌ 图片读取失败: {e}") def _send_file(self): gid, group = self._current_group() if group is None or not self._group_connected(group): return path, _ = QtWidgets.QFileDialog.getOpenFileName(self, "选择文件") if not path: return try: with open(path, 'rb') as f: data = f.read() fname = os.path.basename(path) ext = os.path.splitext(fname)[1].lower() mid = gen_mid() enc = group['session'].encrypt_bytes(data) media_path = save_media(data, ext) content = {'filename': fname, 'size': len(data), 'path': media_path, 'ext': ext} if self._send_file_to(group, fname, enc.encode('utf-8'), mid, 'file'): self._add_local_message(gid, mid, 'file', content) except Exception as e: self._add_system(gid, f"❌ 文件读取失败: {e}\n (可能是文件被占用/权限/或 .lnk 快捷方式问题)") # ---------- 语音 ---------- def _toggle_record(self): """录音: 点一次开始, 再点停止并发送""" gid, group = self._current_group() if self.voice.recording: wav = self.voice.stop_recording() self.record_btn.setText("🎤 录音") if wav: if group and self._group_connected(group): mid = gen_mid() enc = group['session'].encrypt_bytes(wav) media_path = save_media(wav, '.wav') content = {'filename': 'voice.wav', 'size': len(wav), 'path': media_path, 'ext': '.wav'} if self._send_voice_to(group, enc.encode('utf-8'), mid): self._add_local_message(gid, mid, 'voice', content) else: self._add_system(gid, "未连接,语音未发送") else: if group is None or not self._group_connected(group): self._on_status("请先加入一个已连接的群") return if self.voice.start_recording(): self.record_btn.setText("⏹ 停止录音") def _play_voice(self): if self.voice.last_voice: self.voice.play(self.voice.last_voice, self.voice.last_voice_from) else: self._on_status("没有可播放的语音") # ---------- 接收 ---------- def _on_chat(self, gid, sender, text, mid): group = self.groups.get(gid) if not group: return if not group['session'].has_key(): self._add_system(gid, f"⚠️ 无密钥无法解密 {sender} 的消息") return plain = group['session'].decrypt(text) if plain: self._add_message(gid, mid or gen_mid(), sender, 'text', plain, int(time.time())) else: self._add_system(gid, f"⚠️ {sender} 的消息解密失败") def _on_users(self, gid, users): group = self.groups.get(gid) if group: group['users'] = list(users) if gid == self.active_gid: self._render_users(gid) def _on_file(self, gid, msg): group = self.groups.get(gid) if not group: return fname = os.path.basename(str(msg.get('filename', '文件'))) ext = os.path.splitext(fname)[1].lower() mime = msg.get('mime', 'file') raw = b'' try: eb = base64.b64decode(msg.get('data', '')) if eb: raw = group['session'].decrypt_bytes(eb.decode('utf-8')) or b'' except Exception: raw = b'' path = save_media(raw, ext) if raw else '' content = {'filename': fname, 'size': len(raw), 'path': path, 'ext': ext} if mime == 'image' or ext in ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'): mtype = 'image' elif mime in ('voice', 'audio') or ext in ('.wav', '.mp3', '.ogg', '.flac'): mtype = 'audio' else: mtype = 'file' mid = msg.get('mid', gen_mid()) from_user = msg.get('from', '?') ts = int(msg.get('ts', time.time())) self._post(lambda: self._add_message(gid, mid, from_user, mtype, content, ts)) def _on_voice(self, gid, msg): group = self.groups.get(gid) if not group: return raw = b'' try: eb = base64.b64decode(msg.get('data', '')) if eb: raw = group['session'].decrypt_bytes(eb.decode('utf-8')) or b'' except Exception: raw = b'' path = save_media(raw, '.wav') if raw else '' content = {'filename': 'voice.wav', 'size': len(raw), 'path': path, 'ext': '.wav'} mid = msg.get('mid', gen_mid()) from_user = msg.get('from', '?') ts = int(msg.get('ts', time.time())) self._post(lambda: self._add_message(gid, mid, from_user, 'voice', content, ts)) if raw: self.voice.play(raw, from_user) def _on_withdraw(self, gid, mid): if self._delete_message(gid, mid): self._add_system(gid, "🚫 有一条消息已被撤回") def _on_announcement(self, gid, text): group = self.groups.get(gid) if group: group['announcement'] = text if gid == self.active_gid: self._show_banner(text) self._add_system(gid, f"📢 群公告更新: {text}") def _on_group_name(self, gid, name): group = self.groups.get(gid) if group: group['group_name'] = name or '' group['name'] = f"{name or group['my_username']}" self._refresh_groups() def _on_group_info(self, gid, gn, an): if gn: self._on_group_name(gid, gn) if an: self._on_announcement(gid, an) def _on_kick(self, gid, reason): group = self.groups.get(gid) if group: try: group['client'].disconnect() except Exception: pass self._refresh_groups() self._add_system(gid, f"🚫 {reason}") def _on_approval_denied(self, gid, reason): group = self.groups.get(gid) if group: try: group['client'].disconnect() except Exception: pass self._refresh_groups() self._add_system(gid, f"❌ {reason}") def _on_approval_request(self, gid, account, nickname): ret = QtWidgets.QMessageBox.question(self, "入群申请", f"{nickname} ({account}) 曾被移出本群,申请重新加入。是否同意?", QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No) group = self.groups.get(gid) if group: group['client'].send_approval(account, 'grant' if ret == QtWidgets.QMessageBox.Yes else 'deny') def _on_user_key_issued(self, gid, user_key): group = self.groups.get(gid) if group: group['user_key'] = user_key self._save_group(gid) def _send_read(self, gid, mid): """发送已读回执 (P2P 或 服务器群); 未连接时跳过""" group = self.groups.get(gid) if not group: return if group.get('mode') == 'server': sc = group.get('server_conn') if sc and sc.connected: sc.send_message(group['group_id'], {'kind': 'read', 'mid': mid}) elif group.get('client'): group['client'].send_read(mid) def _on_read(self, gid, mid): """收到已读回执: 自己的消息标记为已读""" group = self.groups.get(gid) if not group: return msg = group['messages'].get(mid) if msg and msg.is_mine: msg.read = True if gid == self.active_gid: self.message_list.update_message(mid, msg) def _current_group(self): gid = self.active_gid if gid is None or gid not in self.groups: return None, None return gid, self.groups[gid] # ---------- 中央服务器 (解决 NAT) ---------- def _connect_server(self): from PyQt5.QtWidgets import QInputDialog addr, ok = QInputDialog.getText(self, "中央服务器", "服务器地址 (如 服务器IP:8666):", text="127.0.0.1:8666") if not ok or not addr: return try: host, port = addr.rsplit(':', 1) port = int(port) except Exception: self._on_status("地址格式应为 主机:端口") return # 后台线程连接, 避免卡死 GUI self._on_status(f"☁️ 正在连接 {host}:{port} ...") threading.Thread(target=self._do_manual_connect, args=(host, port), daemon=True).start() def _do_manual_connect(self, host, port): """后台连接服务器, 成功后在主线程弹登录框; 已连接则复用""" if self.server_conn and self.server_conn.connected: self._post(lambda: self._server_auth_dialog(self.server_conn, host, port)) return conn = ServerConnection( on_status=self._on_status, on_groups=lambda gl: self._post(lambda: self._on_server_groups(gl)), on_message=lambda gid, frm, pl: self._post(lambda: self._on_server_group_message(gid, frm, pl)), on_members=lambda gid, mems: self._post(lambda: self._on_server_members(gid, mems)), on_system=lambda gid, t: self._post(lambda: self._on_server_system(gid, t)), on_group_name=lambda gid, n: self._post(lambda: self._on_server_group_name(gid, n)), on_announcement=lambda gid, t: self._post(lambda: self._on_server_announcement(gid, t)), on_group_info=lambda gid, n, a: self._post(lambda: self._on_server_group_info(gid, n, a))) if not conn.connect(host, port): self._post(lambda: self._on_status(f"❌ 连接 {host}:{port} 失败")) return self._post(lambda: self._server_auth_dialog(conn, host, port)) def _server_auth_dialog(self, conn, host, port): dlg = QtWidgets.QDialog(self) dlg.setWindowTitle(f"☁️ 服务器账号 - {host}:{port}") dlg.setFixedSize(380, 330) lay = QtWidgets.QVBoxLayout(dlg) lay.addWidget(QtWidgets.QLabel(f"连接到 {host}:{port}")) u = QtWidgets.QLineEdit(); u.setPlaceholderText("服务器用户名") p = QtWidgets.QLineEdit(); p.setPlaceholderText("密码"); p.setEchoMode(QtWidgets.QLineEdit.Password) lay.addWidget(u); lay.addWidget(p) lab = QtWidgets.QLabel(""); lab.setStyleSheet("color:#e74c3c;") lay.addWidget(lab) row = QtWidgets.QHBoxLayout() b1 = QtWidgets.QPushButton("登录"); b1.setProperty("class", "info") b2 = QtWidgets.QPushButton("注册(服务器建号)"); b2.setProperty("class", "primary") b3 = QtWidgets.QPushButton("取消") row.addWidget(b1); row.addWidget(b2); row.addWidget(b3) lay.addLayout(row) lay.addWidget(QtWidgets.QLabel("🔐 登录成功后会自动记住,下次启动自动登录服务器")) lay.addWidget(QtWidgets.QLabel("如需清除,删除 data\\server_cred.dat")) def finish(ok, err, uname, upass): if not ok: lab.setText(err) return dlg.accept() self.server_conn = conn self.server_addr = f"{host}:{port}" self.server_username = conn.username self._bind_server_conn() self._update_title() # 登录即自动记住服务器账号 save_server_cred(self._get_pass_hash(), f"{host}:{port}", uname, upass) self._on_status("🔐 已记住服务器账号,下次启动自动登录") self._on_status(f"☁️ 已连接中央服务器 {host}:{port} (用户: {conn.username})") self._add_system(None, f"☁️ 已连接中央服务器 {host}:{port},正在加载你的群...") conn.list_groups() def do_auth(action): uname = u.text().strip() upass = p.text() if not uname or not upass: lab.setText("请输入用户名和密码") return b1.setEnabled(False); b2.setEnabled(False) lab.setText("连接中... 请稍候") def run(): if action == 'login': ok, err = conn.login(uname, upass) else: ok, err = conn.register(uname, upass) self._post(lambda: finish(ok, err, uname, upass)) threading.Thread(target=run, daemon=True).start() b1.clicked.connect(lambda: do_auth('login')) b2.clicked.connect(lambda: do_auth('register')) b3.clicked.connect(dlg.reject) dlg.exec_() def _get_pass_hash(self): """取本地账号密码哈希 (解密服务器凭据用); 缺失时查库兜底""" ph = self.account.get('pass_hash', '') or '' if not ph: try: cur = self.account_db.conn.cursor() r = cur.execute("SELECT pass_hash FROM accounts WHERE id=?", (self.account.get('id', -1),)).fetchone() if r: ph = r[0] or '' except Exception: pass return ph def _auto_connect_server(self): """记住我的服务器账号: 自动登录 (后台线程)""" try: cred = load_server_cred(self._get_pass_hash()) except Exception: cred = None if not cred: return addr, username, password = cred try: host, port = addr.rsplit(':', 1) port = int(port) except Exception: return threading.Thread(target=self._do_auto_connect, args=(host, port, username, password, 3), daemon=True).start() def _do_auto_connect(self, host, port, username, password, attempts=3): for attempt in range(attempts): if self.server_conn and self.server_conn.connected: return conn = ServerConnection( on_status=self._on_status, on_groups=lambda gl: self._post(lambda: self._on_server_groups(gl)), on_message=lambda gid, frm, pl: self._post(lambda: self._on_server_group_message(gid, frm, pl)), on_members=lambda gid, mems: self._post(lambda: self._on_server_members(gid, mems)), on_system=lambda gid, t: self._post(lambda: self._on_server_system(gid, t)), on_group_name=lambda gid, n: self._post(lambda: self._on_server_group_name(gid, n)), on_announcement=lambda gid, t: self._post(lambda: self._on_server_announcement(gid, t)), on_group_info=lambda gid, n, a: self._post(lambda: self._on_server_group_info(gid, n, a))) if not conn.connect(host, port): if attempt < attempts - 1: time.sleep(2) continue self._post(lambda: self._on_status("☁️ 服务器自动连接失败(重试3次),请手动「☁️服务器」登录")) return ok, err = conn.login(username, password) if ok: self._post(lambda: self._on_auto_server_logged_in(conn, host, port)) return if attempt < attempts - 1: time.sleep(2) continue self._post(lambda: self._on_status(f"☁️ 服务器自动登录失败: {err},请手动「☁️服务器」登录")) def _update_title(self): """窗口标题显示当前身份: 有服务器账号就显示服务器账号""" if self.server_username: self.setWindowTitle(f"{APP_NAME} - {self.server_username} ☁️服务器 [{self._my_nick()}]") else: self.setWindowTitle(f"{APP_NAME} - {self._my_nick()}") def _bind_server_conn(self): """把已有服务器群绑定到当前连接 + 更新群内身份为服务器账号""" for g in self.groups.values(): if g.get('mode') == 'server': g['server_conn'] = self.server_conn if self.server_username: g['my_username'] = self.server_username self._refresh_groups() def _on_auto_server_logged_in(self, conn, host, port): self.server_conn = conn self.server_addr = f"{host}:{port}" self.server_username = conn.username self._bind_server_conn() self._update_title() self._on_status(f"☁️ 已自动登录中央服务器 {host}:{port} (用户: {conn.username})") self._add_system(None, "☁️ 服务器自动登录成功,正在实时同步群...") conn.list_groups() def _load_cached_server_groups(self): """启动时从本地缓存显示服务器群 (离线也能看到), 连接后实时同步""" try: cred = load_server_cred(self._get_pass_hash()) except Exception: cred = None if not cred: return addr = cred[0] self.server_addr = addr # 先设服务器地址, 供密钥查找/群去重 groups = load_server_groups_cache(addr) if groups: self._apply_groups_to_ui(groups) self._on_status(f"☁️ 已从本地缓存显示 {len(groups)} 个服务器群") def _on_server_groups(self, groups): """服务器实时返回群列表 → 刷新UI + 写本地缓存""" self._apply_groups_to_ui(groups) save_server_groups_cache(self.server_addr, groups) self._on_status(f"☁️ 已实时同步 {len(groups)} 个服务器群") def _apply_groups_to_ui(self, groups): for g in groups: gid = g.get('id', '') gname = g.get('name', '') or gid owner = g.get('owner', '') key = self._get_saved_server_key(self.server_addr, gid) if key: session = SecureSession() try: session.import_key(key) except Exception: session = None if session: if not self._server_group_exists(gid): self._add_server_group(gid, gname, owner, session) ex = self._gid_for_server_group(gid) if ex: self.groups[ex]['server_conn'] = self.server_conn self._on_group_name(ex, gname or gid) ann = g.get('announcement', '') if ann: self.groups[ex]['announcement'] = ann if ex == self.active_gid: self._show_banner(ann) self._refresh_groups() else: self._add_system(None, f"☁️ 群 [{gname}] ({gid}) 需要密钥,点「☁️ 加群」输入群ID和密钥") def _server_group_exists(self, group_id): key = f"server@{self.server_addr}@{group_id}" return any(g['group_key'] == key for g in self.groups.values()) def _gid_for_server_group(self, group_id): for gid, g in self.groups.items(): if g.get('mode') == 'server' and g.get('group_id') == group_id: return gid return None def _add_server_group(self, group_id, group_name, owner, session): gid = self._new_gid() try: srv_host = self.server_addr.split(':')[0] srv_port = int(self.server_addr.split(':')[1]) except Exception: srv_host, srv_port = '127.0.0.1', 8666 group = { 'gid': gid, 'mode': 'server', 'group_key': f"server@{self.server_addr}@{group_id}", 'host': srv_host, 'port': srv_port, 'group_id': group_id, 'server_conn': self.server_conn, 'name': group_name or group_id, 'my_username': self.server_username or self._my_nick(), 'my_account': self.account.get('username', ''), 'is_host': owner == (self.server_username or self.account.get('username', '')), 'server': None, 'client': None, 'session': session, 'users': [], 'messages': {}, 'order': [], 'announcement': '', 'group_name': group_name or '', 'user_key': None } self._register_group(gid, group) self._save_server_group(gid) return gid def _save_server_group(self, gid): group = self.groups.get(gid) if group: try: self.account_db.save_group(self.account['id'], group['group_key'], group['host'], group['port'], group['session'].export_key(), group['name'], 0) except Exception: pass def _get_saved_server_key(self, server_addr, group_id): gkey = f"server@{server_addr}@{group_id}" try: for r in self.account_db.load_groups(self.account['id']): if r['group_key'] == gkey: return r['session_key'] or None except Exception: pass return None def _create_server_group(self): if not self.server_conn or not self.server_conn.connected: self._on_status("请先连接中央服务器 (☁️ 服务器)") return name, ok = QtWidgets.QInputDialog.getText(self, "☁️ 服务器建群", "群名:", text="新群") if not ok or not name: return group_id = f"sg{int(time.time()*1000)}-{os.urandom(3).hex()}" session = SecureSession(); session.generate() self.server_conn.create_group(group_id, name) gid = self._add_server_group(group_id, name, self.server_username, session) self._add_system(gid, f"✅ 已在服务器创建群 [{name}]") self._show_server_group_info(gid) # 显示并复制 群ID + 服务器 + 密钥 self.server_conn.group_members(group_id) def _join_server_group(self): if not self.server_conn or not self.server_conn.connected: self._on_status("请先连接中央服务器 (☁️ 服务器)") return gid2, ok1 = QtWidgets.QInputDialog.getText(self, "☁️ 服务器加群", "群ID (创建者分享):") if not ok1 or not gid2: return key, ok2 = QtWidgets.QInputDialog.getText(self, "☁️ 服务器加群", "群密钥 (创建者分享):") if not ok2 or not key: return session = SecureSession() if not session.import_key(key): self._on_status("密钥无效!") return self.server_conn.join_group(gid2) gid = self._add_server_group(gid2, gid2, '', session) self._add_system(gid, f"✅ 已加入服务器群 [{gid2}]") self.server_conn.group_members(gid2) def _on_server_group_message(self, group_id, sender, payload): gid = self._gid_for_server_group(group_id) if not gid or not isinstance(payload, dict): return kind = payload.get('kind') if kind == 'chat': self._on_chat(gid, sender, payload.get('text', ''), payload.get('mid', '')) elif kind == 'file': self._on_file(gid, {'filename': payload.get('filename', '文件'), 'size': payload.get('size', 0), 'data': payload.get('data', ''), 'mime': payload.get('mime', 'file'), 'mid': payload.get('mid', ''), 'ts': payload.get('ts', int(time.time())), 'from': sender}) elif kind == 'voice': self._on_voice(gid, {'data': payload.get('data', ''), 'size': payload.get('size', 0), 'mid': payload.get('mid', ''), 'ts': payload.get('ts', int(time.time())), 'from': sender}) elif kind == 'withdraw': self._on_withdraw(gid, payload.get('mid', '')) elif kind == 'read': self._on_read(gid, payload.get('mid', '')) def _on_server_system(self, group_id, text): gid = self._gid_for_server_group(group_id) if gid: self._add_system(gid, text) if '加入' in text or '离开' in text: self.server_conn.group_members(group_id) def _on_server_members(self, group_id, members): for gid, g in self.groups.items(): if g.get('mode') == 'server' and g.get('group_id') == group_id: g['users'] = [{'name': m, 'account': m} for m in members] if gid == self.active_gid: self._render_users(gid) break def _on_server_group_name(self, group_id, name): gid = self._gid_for_server_group(group_id) if gid: self._on_group_name(gid, name) self._add_system(gid, f"🏷️ 群名更新为: {name}") def _on_server_group_info(self, group_id, name, announcement): """服务器推送的群信息(加入时): 同步群名/公告""" gid = self._gid_for_server_group(group_id) if not gid: return if name: self._on_group_name(gid, name) if announcement: self._on_announcement(gid, announcement) def _on_server_announcement(self, group_id, text): gid = self._gid_for_server_group(group_id) if gid: self._on_announcement(gid, text) # ---------- 消息操作 ---------- def _on_download(self, mid): gid = self.active_gid group = self.groups.get(gid) if not group: return msg = group['messages'].get(mid) if not msg: return data = msg.get_data_bytes() if not data: self._on_status("文件已清理或不存在") return c = msg.content if isinstance(msg.content, dict) else {} path, _ = QtWidgets.QFileDialog.getSaveFileName(self, "保存文件", os.path.join(DOWNLOAD_DIR, c.get('filename', 'download.bin'))) if not path: return try: with open(path, 'wb') as f: f.write(data) self._add_system(gid, f"✅ 已保存: {path}") except Exception as e: self._add_system(gid, f"❌ 保存失败: {e}") def _on_play(self, mid): """播放语音: 优先读媒体文件; 文件缺失时回退到内存缓存, 保证点了就有声""" gid = self.active_gid group = self.groups.get(gid) msg = group['messages'].get(mid) if group else None if not msg: self._on_status("消息不存在") return data = msg.get_data_bytes() if data: self.voice.last_voice = data self.voice.last_voice_from = msg.sender self.voice.play(data, msg.sender) elif self.voice.last_voice: # 媒体文件缺失 → 用最近一次缓存的语音 self.voice.play(self.voice.last_voice, self.voice.last_voice_from) self._on_status("⚠️ 媒体文件缺失,已用缓存语音播放") else: self._on_status("文件已清理或不存在") def _on_preview(self, mid): gid = self.active_gid group = self.groups.get(gid) msg = group['messages'].get(mid) if group else None if not msg: return data = msg.get_data_bytes() if not data: return try: from PIL import Image img = Image.open(io.BytesIO(data)) img.thumbnail((900, 700)) img = img.convert("RGBA") qimg = QtGui.QImage(img.tobytes(), img.width, img.height, QtGui.QImage.Format_RGBA8888) pix = QtGui.QPixmap.fromImage(qimg) dlg = QtWidgets.QDialog(self) dlg.setWindowTitle(f"图片预览 - {msg.sender}") lab = QtWidgets.QLabel() lab.setPixmap(pix) lay = QtWidgets.QVBoxLayout(dlg) lay.addWidget(lab) close = QtWidgets.QPushButton("关闭") close.clicked.connect(dlg.accept) lay.addWidget(close) dlg.exec_() except Exception as e: self._on_status(f"预览失败: {e}") def _on_msg_selected(self, mid): """消息列表选中/取消回调 → 同步到主窗口 (None=取消选中)""" self.selected_mid = mid def _show_context_menu(self, mid, pos): gid = self.active_gid group = self.groups.get(gid) if not group: return msg = group['messages'].get(mid) if not msg: return self.selected_mid = mid menu = QtWidgets.QMenu(self) if msg.type == 'text': menu.addAction("📋 复制文字", lambda: self._copy_msg(msg)) if msg.type in ('image', 'file', 'audio', 'voice'): menu.addAction("💾 下载", lambda: self._on_download(mid)) if msg.type == 'image': menu.addAction("🔍 查看原图", lambda: self._on_preview(mid)) if msg.type in ('voice', 'audio'): menu.addAction("▶ 播放", lambda: self._on_play(mid)) menu.addSeparator() if msg.is_withdrawable(): menu.addAction("🚫 撤回", lambda: self._withdraw(mid)) menu.addAction("📤 分享到...", lambda: self._share(mid)) menu.exec_(pos) def _copy_msg(self, msg): if msg.type == 'text': QtWidgets.QApplication.clipboard().setText(msg.content) self._on_status("📋 已复制文字") def _withdraw(self, mid): gid = self.active_gid group = self.groups.get(gid) if not group: return msg = group['messages'].get(mid) if not msg or not msg.is_withdrawable(): self._on_status("只能撤回自己 2 分钟内的消息") return self._send_withdraw_to(group, mid) self._delete_message(gid, mid) self._add_system(gid, "🚫 你撤回了一条消息") def _share(self, mid): gid = self.active_gid group = self.groups.get(gid) msg = group['messages'].get(mid) if group else None if not msg or msg.withdrawn: return targets = [g for g in self.order if g != gid] if not targets: self._on_status("没有其他群可以分享") return name, ok = QtWidgets.QInputDialog.getItem(self, "📤 分享到", "选择目标群:", [self.groups[g]['name'] for g in targets], 0, False) if not ok: return g2 = targets[[self.groups[g]['name'] for g in targets].index(name)] group2 = self.groups.get(g2) if not group2 or not self._group_connected(group2): self._on_status("目标群未连接") return mid2 = gen_mid() if msg.type == 'text': enc = group2['session'].encrypt(msg.content) if self._send_chat_to(group2, enc, mid2): self._add_local_message(g2, mid2, 'text', msg.content) elif msg.type in ('image', 'file', 'audio'): c = msg.content raw = msg.get_data_bytes() mime = 'image' if msg.type == 'image' else ('file' if msg.type == 'file' else 'audio') enc = group2['session'].encrypt_bytes(raw) new_path = save_media(raw, c.get('ext', '')) content = {'filename': c.get('filename', 'file'), 'size': len(raw), 'path': new_path, 'ext': c.get('ext', '')} if self._send_file_to(group2, content['filename'], enc.encode('utf-8'), mid2, mime): self._add_local_message(g2, mid2, msg.type, content) elif msg.type == 'voice': raw = msg.get_data_bytes() enc = group2['session'].encrypt_bytes(raw) new_path = save_media(raw, '.wav') content = {'filename': 'voice.wav', 'size': len(raw), 'path': new_path, 'ext': '.wav'} if self._send_voice_to(group2, enc.encode('utf-8'), mid2): self._add_local_message(g2, mid2, 'voice', content) self._switch_group(g2) self._add_system(g2, f"📤 已分享一条来自 {msg.sender} 的消息") # ---------- 密钥 / 管理 / 工具 ---------- def _show_key_dialog(self, gid): group = self.groups.get(gid) if not group or not group['session'].has_key(): return data = group['session'].export_key() dlg = QtWidgets.QDialog(self) dlg.setWindowTitle("🔑 会话密钥") dlg.setFixedSize(460, 200) lay = QtWidgets.QVBoxLayout(dlg) lay.addWidget(QtWidgets.QLabel("复制此密钥分享给加入该群的人")) te = QtWidgets.QTextEdit() te.setPlainText(data) te.setReadOnly(True) lay.addWidget(te) row = QtWidgets.QHBoxLayout() b1 = QtWidgets.QPushButton("📋 复制全部") b1.setProperty("class", "primary") b1.clicked.connect(lambda: (QtWidgets.QApplication.clipboard().setText(data), dlg.accept())) b2 = QtWidgets.QPushButton("关闭") b2.clicked.connect(dlg.reject) row.addWidget(b1); row.addWidget(b2) lay.addLayout(row) dlg.exec_() def _show_server_group_info(self, gid): """服务器建群: 显示并复制 群ID + 服务器地址 + 群密钥 (加入需要)""" group = self.groups.get(gid) if not group: return gid2 = group.get('group_id', '') key = group['session'].export_key() if group['session'].has_key() else '' addr = self.server_addr or '' dlg = QtWidgets.QDialog(self) dlg.setWindowTitle("☁️ 服务器群邀请信息") dlg.setFixedSize(500, 340) lay = QtWidgets.QVBoxLayout(dlg) lay.addWidget(QtWidgets.QLabel("把下面 3 项分享给朋友,让他们点「☁️ 加群」输入后加入")) def make_row(label, value, long=False): lay.addWidget(QtWidgets.QLabel(label)) row = QtWidgets.QHBoxLayout() if long: te = QtWidgets.QTextEdit(value); te.setFixedHeight(56) row.addWidget(te, 1) else: te = QtWidgets.QLineEdit(value); te.setReadOnly(True) row.addWidget(te, 1) btn = QtWidgets.QPushButton("📋 复制") btn.clicked.connect(lambda: (QtWidgets.QApplication.clipboard().setText(value), dlg.accept())) row.addWidget(btn) lay.addLayout(row) make_row("🆔 群ID:", gid2) make_row("🌐 服务器:", addr) make_row("🔑 群密钥:", key, long=True) lay.addWidget(QtWidgets.QLabel("⚠️ 密钥请私下发给成员,勿在公开渠道泄露")) close = QtWidgets.QPushButton("关闭") close.clicked.connect(dlg.reject) lay.addWidget(close) dlg.exec_() def _copy_active_key(self): gid, group = self._current_group() if group and group['session'].has_key(): QtWidgets.QApplication.clipboard().setText(group['session'].export_key()) self._on_status(f"🔑 已复制「{group['name']}」的密钥") else: self._on_status("请先选择一个群") def _rename(self): nick, ok = QtWidgets.QInputDialog.getText(self, "改名", "新昵称:", text=self._my_nick()) if not ok or not nick: return nick = nick[:20] try: self.account_db.update_nickname(self.account['id'], nick) except Exception: pass self.account = dict(self.account) self.account['nickname'] = nick for gid in list(self.order): g = self.groups[gid] g['my_username'] = nick if g.get('mode') == 'server': pass elif g.get('client'): g['client'].send_rename(nick) self._add_system(gid, f"✏️ 你已改名为 {nick}") self._refresh_groups() self._render_users(self.active_gid) self._on_status(f"✅ 已改名为 {nick}") def _set_announcement(self): gid, group = self._current_group() if not group: return if not group['is_host']: self._on_status("仅群主可设置公告") return text, ok = QtWidgets.QInputDialog.getText(self, "群公告", "输入公告内容:", text=group.get('announcement', '')) if not ok: return if group.get('mode') == 'server': group['server_conn'].set_announcement(group['group_id'], text) elif group.get('client'): group['client'].send_announcement(text) group['announcement'] = text if gid == self.active_gid: self._show_banner(text) def _set_group_name(self): gid, group = self._current_group() if not group: return if not group['is_host']: self._on_status("仅群主可设置群名") return cur = group.get('group_name', '') or group.get('group_id', '') name, ok = QtWidgets.QInputDialog.getText(self, "群名", "输入群名:", text=cur) if not ok or not name: return if group.get('mode') == 'server': group['server_conn'].set_group_name(group['group_id'], name) elif group.get('client'): group['client'].send_set_group_name(name) self._on_group_name(gid, name) def _withdraw_selected(self): if self.selected_mid: self._withdraw(self.selected_mid) else: self._on_status("请先点击选择一条消息") def _share_selected(self): if self.selected_mid: self._share(self.selected_mid) else: self._on_status("请先点击选择一条消息") def _download_selected(self): if self.selected_mid: self._on_download(self.selected_mid) else: self._on_status("请先点击选择一条消息") def _copy_selected(self): gid = self.active_gid group = self.groups.get(gid) msg = group['messages'].get(self.selected_mid) if group and self.selected_mid else None if msg: self._copy_msg(msg) else: self._on_status("请先点击选择一条消息") def _leave_active_group(self): if self.active_gid: self._leave_group(self.active_gid) def _leave_group(self, gid): group = self.groups.get(gid) if not group: return if group.get('mode') == 'server': try: group['server_conn'].leave_group(group['group_id']) except Exception: pass else: try: group['client'].disconnect() except Exception: pass if group['server']: try: group['server'].stop() except Exception: pass if gid in self.order: self.order.remove(gid) del self.groups[gid] try: self.account_db.remove_group(self.account['id'], group['group_key']) except Exception: pass if self.active_gid == gid: self.active_gid = self.order[-1] if self.order else None self._render_group(self.active_gid) self._refresh_groups() def _on_group_menu(self, pos): item = self.group_list.itemAt(pos) if not item: return gid = item.data(QtCore.Qt.UserRole) menu = QtWidgets.QMenu(self) menu.addAction("🔑 复制密钥", lambda: self._copy_group_key(gid)) menu.addAction("💾 导出密钥文件", lambda: self._save_key_file(gid)) menu.addAction("📤 分享密钥到其他群", lambda: self._share_key_to(gid)) menu.addAction("📋 群属性", lambda: self._group_properties(gid)) menu.addSeparator() menu.addAction("切换到此群", lambda: self._switch_group(gid)) menu.addAction("❌ 离开该群", lambda: self._leave_group(gid)) menu.exec_(self.group_list.mapToGlobal(pos)) def _copy_group_key(self, gid): group = self.groups.get(gid) if group and group['session'].has_key(): QtWidgets.QApplication.clipboard().setText(group['session'].export_key()) self._on_status(f"🔑 已复制「{group['name']}」的密钥") def _save_key_file(self, gid): group = self.groups.get(gid) if not group or not group['session'].has_key(): return data = group['session'].export_key() path, _ = QtWidgets.QFileDialog.getSaveFileName(self, "导出密钥", f"群密钥_{gid}.txt", "文本 (*.txt)") if not path: return try: with open(path, 'w', encoding='utf-8') as f: f.write(data) self._on_status(f"✅ 密钥已导出: {path}") except Exception as e: self._on_status(f"❌ 导出失败: {e}") def _share_key_to(self, gid): group = self.groups.get(gid) if not group or not group['session'].has_key(): return data = group['session'].export_key() targets = [g for g in self.order if g != gid] if not targets: self._on_status("没有其他群可以分享") return name, ok = QtWidgets.QInputDialog.getItem(self, "📤 分享密钥到", "选择目标群:", [self.groups[g]['name'] for g in targets], 0, False) if not ok: return g2 = targets[[self.groups[g]['name'] for g in targets].index(name)] QtWidgets.QApplication.clipboard().setText(data) self._switch_group(g2) self._add_system(g2, f"📤 已复制「{group['name']}」的密钥,可粘贴给群成员加入该群") def _group_properties(self, gid): group = self.groups.get(gid) if not group: return mode = group.get('mode', 'p2p') mode_text = '🔗 端到端 (P2P)' if mode != 'server' else '☁️ 中央服务器' gid_field = group.get('group_id', '(无)') if mode == 'server' else '(无)' status = '🟢 在线' if self._group_connected(group) else '🔴 离线' info = (f"📋 群名: {group.get('group_name', '') or group['name']}\n" f"🏷️ 类型: {mode_text}\n" f"🆔 群ID: {gid_field}\n" f"🌐 地址: {group['host']}:{group['port']}\n" f"📡 状态: {status}\n" f"👑 身份: {'是 [群主]' if group['is_host'] else '否 [成员]'}\n" f"🔑 加密: {'✅ 已加密' if group['session'].has_key() else '❌ 无密钥'}\n" f"👥 成员: {len(group['users'])} 人") QtWidgets.QMessageBox.information(self, "📋 群属性", info) def _on_user_menu(self, pos): gid, group = self._current_group() if not group: return item = self.users_list.itemAt(pos) if not item: return if group.get('mode') == 'server': self._on_status("服务器群由服务器管理成员") return if not group['is_host']: self._on_status("仅群主可管理用户") return idx = self.users_list.row(item) if idx < 0 or idx >= len(group['users']): return target = group['users'][idx] if target.get('account') == self.account.get('username', ''): return if QtWidgets.QMessageBox.question(self, "踢人", f"确定将 {target.get('name','')} 移出群吗?", QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No) == QtWidgets.QMessageBox.Yes: group['client'].send_kick(target.get('account', '')) def _clear_media_cache(self): if QtWidgets.QMessageBox.question(self, "清理媒体缓存", "将删除「文档/ChaosChat/media」中所有文件/图片/语音,历史记录中的这些文件将无法打开。确定?", QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No) == QtWidgets.QMessageBox.Yes: n = clear_media_cache() self._on_status(f"🗑️ 已清理 {n} 个媒体文件") def _open_download_dir(self): try: os.startfile(DOWNLOAD_DIR) except Exception: pass # ---------- 自动加入 ---------- def _start_auto_join(self): threading.Thread(target=self._auto_join_worker, daemon=True).start() def _auto_join_worker(self): try: rows = self.account_db.load_groups(self.account['id']) except Exception: rows = [] for r in rows: if r['group_key'] and str(r['group_key']).startswith('server@'): continue self._auto_join_one(r) def _auto_join_one(self, r, attempts=3): host, port = r['host'], r['port'] key = f"{host}:{port}" for g in list(self.groups.values()): if g['group_key'] == key and self._group_connected(g): return session = SecureSession() try: session.import_key(r['session_key']) except Exception: pass gid = self._new_gid() for attempt in range(attempts): server = None if r['is_host']: server = ChatServer(on_status=lambda t: self._post(lambda: self._on_status(t)), server_key=key, server_db=self.server_db, server_secret=session.session_key) if not server.start(port): server = None if attempt < attempts - 1: time.sleep(2) continue return client = self._make_client(gid) saved_uk = r['user_key'] if 'user_key' in r.keys() else None ok = client.connect(host, port, self._my_nick(), nickname=self._my_nick(), account=self.account.get('username', ''), auth_secret=session.auth_secret(), user_key=saved_uk, user_key_enc=session.user_key_enc()) if ok: group = self._build_group(gid, host, port, self._my_nick(), bool(r['is_host']), server, client, session) group['user_key'] = saved_uk self._post(lambda gid=gid, group=group: self._register_group(gid, group)) return if server: try: server.stop() except Exception: pass if attempt < attempts - 1: time.sleep(2) # ---------- 退出 ---------- def _logout(self): clear_session() self._relogin = True self._close_all() def _quit(self): self._relogin = False self._close_all() def _close_all(self): try: if self.server_conn: self.server_conn.disconnect() except Exception: pass for gid in list(self.order): g = self.groups.get(gid) if g: try: if g.get('client'): g['client'].disconnect() except Exception: pass if g.get('server'): try: g['server'].stop() except Exception: pass try: self.history.conn.close() except Exception: pass try: self.account_db.conn.close() except Exception: pass self.close() def closeEvent(self, event): self._close_all() event.accept() # ==================== 主入口 ==================== def main(): import sys app = QtWidgets.QApplication(sys.argv) app.setStyleSheet(QSS) db = AccountDB() # 自动登录 (记住我) account = None token = load_session() if token: account = db.validate_session(token) if account: print(f"✅ 自动登录: {account['username']}") if account is None: login = LoginDialog(db) if login.exec_() != QtWidgets.QDialog.Accepted: return account = login.result # 本地登录不强制记住; 保持简单 token = db.create_session(account['id']) save_session(token) win = MainWindow(account) win.show() app.exec_() if __name__ == "__main__": main()