Update client.py

This commit is contained in:
lohrrrr 2026-05-19 02:19:00 +03:00
parent 5196383bc3
commit cfd13cfad8

595
client.py
View file

@ -16,26 +16,16 @@ def auto_install_deps():
__import__(import_name) __import__(import_name)
except ImportError: except ImportError:
missing_packages.append(pip_name) missing_packages.append(pip_name)
if not missing_packages: if not missing_packages:
return return
print("[Auto-Installer] Installing client dependencies:", ", ".join(missing_packages))
cmd = [sys.executable, "-m", "pip", "install"] + missing_packages cmd = [sys.executable, "-m", "pip", "install"] + missing_packages
result = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) result = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
if result.returncode != 0: if result.returncode != 0:
stderr_output = result.stderr.decode('utf-8', errors='ignore') stderr_output = result.stderr.decode('utf-8', errors='ignore')
if "externally-managed-environment" in stderr_output.lower(): if "externally-managed-environment" in stderr_output.lower():
print("\n" + "!"*50)
print("[Warning] OS blocks global package installation via pip.")
print("!"*50 + "\n")
choice = input("Use --break-system-packages flag? (y/n): ").strip().lower()
if choice == 'y':
force_cmd = cmd + ["--break-system-packages"] force_cmd = cmd + ["--break-system-packages"]
force_result = subprocess.run(force_cmd) force_result = subprocess.run(force_cmd)
if force_result.returncode == 0: if force_result.returncode == 0:
print("[Success] Restart the script!")
sys.exit(0) sys.exit(0)
sys.exit(1) sys.exit(1)
@ -54,13 +44,15 @@ import socketio
from prompt_toolkit.application import Application from prompt_toolkit.application import Application
from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.layout.containers import HSplit, VSplit, Window from prompt_toolkit.layout.containers import HSplit, VSplit, Window, FloatContainer, Float, ConditionalContainer, WindowAlign
from prompt_toolkit.layout.controls import FormattedTextControl from prompt_toolkit.layout.controls import FormattedTextControl
from prompt_toolkit.layout.layout import Layout from prompt_toolkit.layout.layout import Layout
from prompt_toolkit.widgets import TextArea, Frame from prompt_toolkit.widgets import TextArea, Frame
from prompt_toolkit.styles import Style from prompt_toolkit.styles import Style
from prompt_toolkit.application.current import get_app from prompt_toolkit.application.current import get_app
from prompt_toolkit.filters import has_focus from prompt_toolkit.filters import has_focus, Condition
from prompt_toolkit.data_structures import Point
from prompt_toolkit.completion import Completer, Completion
BASE_FREQ = 600 BASE_FREQ = 600
FREQ_STEP = 25 FREQ_STEP = 25
@ -70,6 +62,11 @@ CONFIG_FILE = "settings.json"
WIKI_URL = "https://git.idkmail.ru/lohrrrr/TyChat-Client/wiki" WIKI_URL = "https://git.idkmail.ru/lohrrrr/TyChat-Client/wiki"
DEV_SIGNATURE = "For you, from IDKMail Dev Group" DEV_SIGNATURE = "For you, from IDKMail Dev Group"
FREQ_QUERY = 1200
FREQ_ALERT = 1400
FREQ_RESP_YES = 1600
FREQ_RESP_NO = 1800
ASCII_ART = r""" ASCII_ART = r"""
_______ _____ _ _ _______ _____ _ _
|__ __| / ____| | | | |__ __| / ____| | | |
@ -83,6 +80,26 @@ ASCII_ART = r"""
sio = socketio.Client() sio = socketio.Client()
class CommandCompleter(Completer):
def __init__(self):
self.completions = {
"/add": "Добавить контакт (/add <uin>)",
"/busy": "Режим DND (Не беспокоить)",
"/query": "Мягкий запрос статуса (Уважает DND)",
"/alert": "Экстренный вызов (Пробивает DND)",
"/room create ": "Создать комнату (/room create \"имя\")",
"/room join ": "Войти в комнату (/room join UIN:\"имя\")",
"/exit": "Выход из мессенджера",
"/help": "Открыть Wiki проекта"
}
def get_completions(self, document, complete_event):
text = document.text
if text.startswith('/'):
for cmd, desc in self.completions.items():
if cmd.startswith(text):
yield Completion(cmd, start_position=-len(text), display_meta=desc)
class TyClient: class TyClient:
def __init__(self): def __init__(self):
self.server_url = "" self.server_url = ""
@ -90,13 +107,21 @@ class TyClient:
self.uin = "" self.uin = ""
self.password = "" self.password = ""
self.contacts = {} self.contacts = {}
self.groups = {}
self.history = {} self.history = {}
self.preserved = {} self.preserved = {}
self.blocklist = []
self.active_chat = None self.active_chat = None
self.app = None self.app = None
self.loop_running = True self.loop_running = True
self.selected_contact_idx = 0 self.selected_contact_idx = 0
self.is_busy = False
self.dialing_uin = None
self.pending_requests = []
self.pending_windows = []
self.show_welcome_popup = False
def load_config(self): def load_config(self):
if os.path.exists(CONFIG_FILE): if os.path.exists(CONFIG_FILE):
try: try:
@ -107,8 +132,10 @@ class TyClient:
self.uin = cfg.get("uin", "") self.uin = cfg.get("uin", "")
self.password = cfg.get("password", "") self.password = cfg.get("password", "")
self.contacts = cfg.get("contacts", {}) self.contacts = cfg.get("contacts", {})
self.groups = cfg.get("groups", {})
self.history = cfg.get("history", {}) self.history = cfg.get("history", {})
self.preserved = cfg.get("preserved", {}) self.preserved = cfg.get("preserved", {})
self.blocklist = cfg.get("blocklist", [])
return True return True
except: except:
return False return False
@ -121,12 +148,35 @@ class TyClient:
"uin": self.uin, "uin": self.uin,
"password": self.password, "password": self.password,
"contacts": self.contacts, "contacts": self.contacts,
"groups": self.groups,
"history": self.history, "history": self.history,
"preserved": self.preserved "preserved": self.preserved,
"blocklist": self.blocklist
} }
with open(CONFIG_FILE, "w", encoding="utf-8") as f: with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=4, ensure_ascii=False) json.dump(cfg, f, indent=4, ensure_ascii=False)
def play_dial_tones(self, target_uin):
self.dialing_uin = target_uin
while self.dialing_uin == target_uin and self.loop_running:
t = np.linspace(0, 1.0, int(SAMPLE_RATE * 1.0), False)
tone = np.sin(425 * t * 2 * np.pi) * 0.5
sd.play(tone, SAMPLE_RATE)
sd.wait()
for _ in range(30):
if self.dialing_uin != target_uin or not self.loop_running:
break
time.sleep(0.1)
def generate_service_tone(self, frequency, duration=0.4):
t = np.linspace(0, duration, int(SAMPLE_RATE * duration), False)
tone = np.sin(frequency * t * 2 * np.pi) * 0.7
envelope = np.ones_like(tone)
fade_len = int(SAMPLE_RATE * 0.05)
envelope[:fade_len] = np.linspace(0, 1, fade_len)
envelope[-fade_len:] = np.linspace(1, 0, fade_len)
return (tone * envelope).astype(np.float32).tobytes()
def text_to_audio(self, text): def text_to_audio(self, text):
audio_signals = [] audio_signals = []
t_start = np.linspace(0, 0.3, int(SAMPLE_RATE * 0.3), False) t_start = np.linspace(0, 0.3, int(SAMPLE_RATE * 0.3), False)
@ -140,6 +190,40 @@ class TyClient:
full_audio = full_audio / np.max(np.abs(full_audio)) full_audio = full_audio / np.max(np.abs(full_audio))
return full_audio.tobytes() return full_audio.tobytes()
def detect_service_tone(self, audio_data):
try:
window_data = np.hanning(len(audio_data))
fft_data = np.abs(np.fft.rfft(audio_data * window_data))
frequencies = np.fft.rfftfreq(len(audio_data), d=1/SAMPLE_RATE)
detected_freq = frequencies[np.argmax(fft_data)]
for target in [FREQ_QUERY, FREQ_ALERT, FREQ_RESP_YES, FREQ_RESP_NO]:
if abs(detected_freq - target) <= 15:
return target
return None
except:
return None
def fast_decode(self, audio_data):
try:
samples_per_tone = int(SAMPLE_RATE * TONE_DURATION)
current_sample = int(SAMPLE_RATE * 0.3)
text = ""
while current_sample < len(audio_data):
chunk = audio_data[current_sample : current_sample + samples_per_tone]
if len(chunk) < samples_per_tone:
break
window_data = np.hanning(len(chunk))
fft_data = np.abs(np.fft.rfft(chunk * window_data))
frequencies = np.fft.rfftfreq(len(chunk), d=1/SAMPLE_RATE)
detected_freq = frequencies[np.argmax(fft_data)]
ascii_code = int(round((detected_freq - BASE_FREQ) / FREQ_STEP))
if 0 <= ascii_code < 65535:
text += chr(ascii_code)
current_sample += samples_per_tone
return text
except:
return ""
def play_and_decode(self, audio_bytes, sender_uin): def play_and_decode(self, audio_bytes, sender_uin):
audio_data = np.frombuffer(audio_bytes, dtype=np.float32) audio_data = np.frombuffer(audio_bytes, dtype=np.float32)
sd.play(audio_data, SAMPLE_RATE) sd.play(audio_data, SAMPLE_RATE)
@ -155,13 +239,12 @@ class TyClient:
self.history[sender_uin].append(msg_buffer) self.history[sender_uin].append(msg_buffer)
self.refresh_ui() self.refresh_ui()
full_text_received = ""
while current_sample < len(audio_data) and self.loop_running: while current_sample < len(audio_data) and self.loop_running:
chunk = audio_data[current_sample : current_sample + samples_per_tone] chunk = audio_data[current_sample : current_sample + samples_per_tone]
if len(chunk) < samples_per_tone: if len(chunk) < samples_per_tone:
break break
window = np.hanning(len(chunk)) window_data = np.hanning(len(chunk))
fft_data = np.abs(np.fft.rfft(chunk * window)) fft_data = np.abs(np.fft.rfft(chunk * window_data))
frequencies = np.fft.rfftfreq(len(chunk), d=1/SAMPLE_RATE) frequencies = np.fft.rfftfreq(len(chunk), d=1/SAMPLE_RATE)
detected_freq = frequencies[np.argmax(fft_data)] detected_freq = frequencies[np.argmax(fft_data)]
ascii_code = int(round((detected_freq - BASE_FREQ) / FREQ_STEP)) ascii_code = int(round((detected_freq - BASE_FREQ) / FREQ_STEP))
@ -169,17 +252,11 @@ class TyClient:
try: try:
char = chr(ascii_code) char = chr(ascii_code)
self.history[sender_uin][-1] += char self.history[sender_uin][-1] += char
full_text_received += char
self.refresh_ui() self.refresh_ui()
except: except:
pass pass
time.sleep(TONE_DURATION) time.sleep(TONE_DURATION)
current_sample += samples_per_tone current_sample += samples_per_tone
if "ALARM! URGENT CALL!" in full_text_received:
client.contacts[sender_uin]["attention"] = True
self.refresh_ui()
sd.wait() sd.wait()
self.save_config() self.save_config()
@ -190,6 +267,14 @@ class TyClient:
self.refresh_ui() self.refresh_ui()
self.save_config() self.save_config()
def send_sys_packet(self, to_uin, cmd):
audio_b = self.text_to_audio(f"SYS:{cmd}")
sio.emit("relay_packet", {"to_uin": to_uin, "payload": base64.b64encode(audio_b).decode('utf-8')})
def send_service_tone(self, to_uin, freq):
audio_b = self.generate_service_tone(freq)
sio.emit("relay_packet", {"to_uin": to_uin, "payload": base64.b64encode(audio_b).decode('utf-8')})
def refresh_ui(self): def refresh_ui(self):
if self.app: if self.app:
self.app.invalidate() self.app.invalidate()
@ -202,12 +287,75 @@ def incoming_packet(data):
payload_base64 = data["payload"] payload_base64 = data["payload"]
try: try:
audio_bytes = base64.b64decode(payload_base64.encode('utf-8')) audio_bytes = base64.b64decode(payload_base64.encode('utf-8'))
audio_data = np.frombuffer(audio_bytes, dtype=np.float32)
srv_tone = client.detect_service_tone(audio_data)
if srv_tone:
sd.play(audio_data, SAMPLE_RATE)
if from_uin not in client.contacts: if from_uin not in client.contacts:
client.contacts[from_uin] = {"status": "offline", "unread": 0, "attention": False} return
if srv_tone == FREQ_QUERY:
if client.is_busy:
client.send_service_tone(from_uin, FREQ_RESP_YES)
else:
client.pending_windows.append({"type": "query", "uin": from_uin})
client.refresh_ui()
return
elif srv_tone == FREQ_ALERT:
client.pending_windows.append({"type": "alert", "uin": from_uin})
client.refresh_ui()
return
elif srv_tone == FREQ_RESP_YES:
client.add_to_history(from_uin, f"[SYSTEM]: Quick status answer -> Yes, I'm busy")
return
elif srv_tone == FREQ_RESP_NO:
client.add_to_history(from_uin, f"[SYSTEM]: Quick status answer -> No, go on")
return
fast_text = client.fast_decode(audio_data)
if fast_text.startswith("SYS:"):
cmd = fast_text[4:]
if cmd.startswith("ROOM_MSG:"):
parts = cmd.split(":", 3)
if len(parts) >= 4:
room_id = parts[1]
sender_uin = parts[2]
actual_msg = parts[3]
if room_id in client.groups:
client.add_to_history(room_id, f"[UIN {sender_uin}]: {actual_msg}")
return
elif cmd == "REQ_ADD":
if from_uin in client.blocklist:
client.send_sys_packet(from_uin, "RES_DEC")
elif client.is_busy:
client.send_sys_packet(from_uin, "RES_BSY")
else:
if from_uin not in client.pending_requests and from_uin not in client.contacts:
client.pending_requests.append(from_uin)
client.refresh_ui()
return
elif cmd == "RES_ACC":
if client.dialing_uin == from_uin:
client.dialing_uin = None
if from_uin not in client.contacts:
client.contacts[from_uin] = {"status": "online", "unread": 0, "attention": False}
client.history[from_uin] = ["[SYSTEM]: Handshake accepted. Contact added."]
client.save_config()
client.active_chat = from_uin
client.refresh_ui()
return
elif cmd == "RES_DEC" or cmd == "RES_BSY":
if client.dialing_uin == from_uin:
client.dialing_uin = None
return
if from_uin not in client.contacts:
return
if client.active_chat != from_uin: if client.active_chat != from_uin:
client.contacts[from_uin]["unread"] += 1 client.contacts[from_uin]["unread"] += 1
threading.Thread(target=client.play_and_decode, args=(audio_bytes, from_uin), daemon=True).start() threading.Thread(target=client.play_and_decode, args=(audio_bytes, from_uin), daemon=True).start()
except: except Exception as e:
pass pass
@sio.event @sio.event
@ -222,13 +370,11 @@ def error(data):
msg = data.get("message") msg = data.get("message")
target = data.get("target_uin") target = data.get("target_uin")
if msg == "offline" and target: if msg == "offline" and target:
if client.dialing_uin == target:
client.dialing_uin = None
if target in client.contacts: if target in client.contacts:
client.contacts[target]["status"] = "offline" client.contacts[target]["status"] = "offline"
client.add_to_history(target, f"[SYSTEM]: They went offline. Everything that you will send now will be sent if they will back online while your client opened.") client.add_to_history(target, f"[SYSTEM]: They went offline. Outgoing stack will be preserved.")
if target in client.preserved:
if len(client.preserved[target]) > 0:
last_p = client.preserved[target][-1]
client.add_to_history(target, f"[You]: {last_p} (Preserved)")
def status_checker_thread(): def status_checker_thread():
while client.loop_running: while client.loop_running:
@ -250,25 +396,20 @@ def make_layout():
tokens = [] tokens = []
for idx, (uin, info) in enumerate(client.contacts.items()): for idx, (uin, info) in enumerate(client.contacts.items()):
status_str = "" status_str = ""
if info.get("attention"): if info.get("attention"): status_str = " [!]"
status_str = " [!]" elif info.get("unread", 0) > 0: status_str = f" ({info['unread']})"
elif info.get("unread", 0) > 0: elif info.get("status") == "online": status_str = " *"
status_str = f" ({info['unread']})"
elif info.get("status") == "online":
status_str = " *"
content = f" {uin}{status_str}" content = f" {uin}{status_str}"
if client.active_chat == uin: style = "class:contact-active"
elif idx == client.selected_contact_idx and get_app().layout.has_focus(sidebar_window): style = "class:contact-focused"
else: style = "class:contact"
tokens.extend([(style, f"{content}\n")])
if client.active_chat == uin: for g_id, g_info in client.groups.items():
style = "class:contact-active" content = f" [G] {g_info['title']}"
elif idx == client.selected_contact_idx and get_app().layout.has_focus(sidebar_window): if client.active_chat == g_id: style = "class:contact-active"
style = "class:contact-focused" else: style = "class:contact"
else: tokens.extend([(style, f"{content}\n")])
style = "class:contact"
tokens.extend([
(style, f"{content}\n"),
])
return tokens return tokens
def get_main_text(): def get_main_text():
@ -281,10 +422,15 @@ def make_layout():
("class:desc", "Messenger on custom protocol named\n"), ("class:desc", "Messenger on custom protocol named\n"),
("class:desc", "AcoustiOverSocket inspired by rtty\n"), ("class:desc", "AcoustiOverSocket inspired by rtty\n"),
("", "\n"), ("", "\n"),
("class:help-tip", "Type /help to open project Wiki\n"), ("class:help-tip", "Type / to trigger interactive command menu\n"),
("", "\n" * 2), ("", "\n" * 2),
("class:help-tip", f"{DEV_SIGNATURE}\n") ("class:help-tip", f"{DEV_SIGNATURE}\n")
]) ])
if client.dialing_uin:
tokens.extend([
("", "\n" * 2),
("class:system", f"Dialing UIN {client.dialing_uin}... Waiting for accept.\n")
])
return tokens return tokens
tokens = [] tokens = []
@ -298,31 +444,36 @@ def make_layout():
tokens.append(("", line + "\n")) tokens.append(("", line + "\n"))
return tokens return tokens
def get_cursor_pos():
text = "".join(t[1] for t in get_main_text())
newlines = text.count('\n')
return Point(0, max(0, newlines - 1))
sidebar_control = FormattedTextControl(get_sidebar_text, focusable=True) sidebar_control = FormattedTextControl(get_sidebar_text, focusable=True)
sidebar_window = Frame(Window(content=sidebar_control, width=25), title="chats", style="class:border") sidebar_window = Frame(Window(content=sidebar_control, width=25), title="chats", style="class:border")
main_control = FormattedTextControl(get_main_text, get_cursor_position=get_cursor_pos)
main_control = FormattedTextControl(get_main_text)
def get_main_title(): def get_main_title():
if client.active_chat: base_title = f"TyChat | You: {client.username} ({client.uin})"
return f"TyChat | You: {client.username} ({client.uin}) | Chat with UIN: {client.active_chat}" if client.is_busy: base_title += " [DND/BUSY]"
return f"TyChat | You: {client.username} ({client.uin})" if client.active_chat: return f"{base_title} | Chat: {client.active_chat}"
return base_title
main_window = Frame(Window(content=main_control), title=get_main_title, style="class:border") main_window = Frame(Window(content=main_control, wrap_lines=True), title=get_main_title, style="class:border")
input_field = TextArea( input_field = TextArea(
height=3, height=3,
prompt="> ", prompt="> ",
multiline=False, multiline=False,
wrap_lines=True wrap_lines=True,
completer=CommandCompleter(),
complete_while_typing=True
) )
input_window = Frame(input_field, title="Input")
input_window = Frame(input_field, title="Type message and press Enter (/exit to quit)")
def accept_handler(buff): def accept_handler(buff):
text = input_field.text.strip() text = input_field.text.strip()
if not text: if not text: return
return
if text.lower() == "/exit": if text.lower() == "/exit":
client.loop_running = False client.loop_running = False
@ -331,108 +482,254 @@ def make_layout():
return return
if text.lower() == "/help": if text.lower() == "/help":
try: try: webbrowser.open(WIKI_URL)
webbrowser.open(WIKI_URL) except: pass
input_field.text = ""
return
if text.lower() == "/busy":
client.is_busy = not client.is_busy
if client.active_chat: if client.active_chat:
client.add_to_history(client.active_chat, "[SYSTEM]: Wiki link opened in browser.") status_msg = "enabled" if client.is_busy else "disabled"
except: client.add_to_history(client.active_chat, f"[SYSTEM]: DND Mode {status_msg}.")
if client.active_chat:
client.add_to_history(client.active_chat, f"[SYSTEM]: Failed to open browser. Wiki: {WIKI_URL}")
input_field.text = "" input_field.text = ""
client.refresh_ui() client.refresh_ui()
return return
if text.lower().startswith("/add "): if text.lower().startswith("/add "):
new_uin = text.split(" ", 1)[1].strip() new_uin = text.split(" ", 1)[1].strip()
if new_uin and new_uin not in client.contacts: if not new_uin: return
client.contacts[new_uin] = {"status": "offline", "unread": 0, "attention": False} if new_uin in client.contacts:
client.history[new_uin] = [] client.active_chat = new_uin
client.save_config() input_field.text = ""
client.refresh_ui()
return
client.active_chat = None
threading.Thread(target=client.play_dial_tones, args=(new_uin,), daemon=True).start()
client.send_sys_packet(new_uin, "REQ_ADD")
input_field.text = "" input_field.text = ""
client.refresh_ui() client.refresh_ui()
return return
if text.lower().startswith("/room create "):
match = re.match(r'^/room create "([^"]+)"$', text, re.IGNORECASE)
if match:
r_name = match.group(1)
r_id = f"ROOM:{client.uin}:{r_name}"
client.groups[r_id] = {"title": r_name, "owner": client.uin, "members": [client.uin]}
client.history[r_id] = [f"[SYSTEM]: Room \"{r_name}\" created successfully. Local GUID: {r_id}"]
client.active_chat = r_id
client.save_config()
client.refresh_ui()
input_field.text = ""
return
if text.lower().startswith("/room join "):
match = re.match(r'^/room join ([0-9]+):"([^"]+)"$', text, re.IGNORECASE)
if match:
r_owner = match.group(1)
r_name = match.group(2)
r_id = f"ROOM:{r_owner}:{r_name}"
if r_owner not in client.contacts and r_owner != client.uin:
client.contacts[r_owner] = {"status": "offline", "unread": 0, "attention": False}
client.groups[r_id] = {"title": r_name, "owner": r_owner, "members": list(set([client.uin, r_owner]))}
client.history[r_id] = [f"[SYSTEM]: Joined room \"{r_name}\". Local GUID: {r_id}"]
client.active_chat = r_id
client.save_config()
client.refresh_ui()
client.send_sys_packet(r_owner, f"REQ_JOIN_ROOM:{r_name}")
input_field.text = ""
return
if client.active_chat: if client.active_chat:
if len(text) > 100: if client.active_chat.startswith("ROOM:"):
group = client.groups[client.active_chat]
owner_uin = group["owner"]
if owner_uin != client.uin and client.contacts.get(owner_uin, {}).get("status") != "online":
client.add_to_history(client.active_chat, "[SYSTEM]: CANNOT SEND. Owner of this room is offline for sync!")
input_field.text = ""
return
audio_b = client.text_to_audio(f"SYS:ROOM_MSG:{client.active_chat}:{client.uin}:{text}")
p_b64 = base64.b64encode(audio_b).decode('utf-8')
if owner_uin == client.uin:
for m in group["members"]:
if m != client.uin and client.contacts.get(m, {}).get("status") == "online":
sio.emit("relay_packet", {"to_uin": m, "payload": p_b64})
else:
sio.emit("relay_packet", {"to_uin": owner_uin, "payload": p_b64})
client.add_to_history(client.active_chat, f"[You]: {text}")
input_field.text = ""
return
if text.lower() == "/query":
client.send_service_tone(client.active_chat, FREQ_QUERY)
client.add_to_history(client.active_chat, "[SYSTEM]: Sent friendly status query...")
input_field.text = ""
return
if text.lower() == "/alert":
client.send_service_tone(client.active_chat, FREQ_ALERT)
client.add_to_history(client.active_chat, "[SYSTEM]: Sent URGENT alert signal (breaks DND)...")
input_field.text = ""
return
if len(text) > 300:
client.add_to_history(client.active_chat, f"[SYSTEM]: Message too long!") client.add_to_history(client.active_chat, f"[SYSTEM]: Message too long!")
input_field.text = "" input_field.text = ""
return return
match = re.match(r'^s/([^/]+)/([^/]*)/?$', text)
if match:
search_str, replace_str = match.groups()
lines = client.history.get(client.active_chat, [])
edited = False
for i in range(len(lines) - 1, -1, -1):
if lines[i].startswith(f"[{client.username}]:") or lines[i].startswith("[You]:"):
if search_str in lines[i]:
lines[i] = lines[i].replace(search_str, replace_str)
edited = True
break
if edited:
client.refresh_ui()
client.save_config()
input_field.text = ""
return
if text.startswith("/alert"):
text = "ALARM! URGENT CALL!"
client.contacts[client.active_chat]["attention"] = True
audio_b = client.text_to_audio(text) audio_b = client.text_to_audio(text)
p_b64 = base64.b64encode(audio_b).decode('utf-8') p_b64 = base64.b64encode(audio_b).decode('utf-8')
if client.contacts[client.active_chat]["status"] == "online": if client.contacts[client.active_chat]["status"] == "online":
sio.emit("relay_packet", {"to_uin": client.active_chat, "payload": p_b64}) sio.emit("relay_packet", {"to_uin": client.active_chat, "payload": p_b64})
client.add_to_history(client.active_chat, f"[You]: {text}") client.add_to_history(client.active_chat, f"[You]: {text}")
else: else:
if client.active_chat not in client.preserved: if client.active_chat not in client.preserved: client.preserved[client.active_chat] = []
client.preserved[client.active_chat] = []
client.preserved[client.active_chat].append(text) client.preserved[client.active_chat].append(text)
client.add_to_history(client.active_chat, f"[You]: {text} (Preserved)") client.add_to_history(client.active_chat, f"[You]: {text} (Preserved)")
client.save_config() client.save_config()
input_field.text = "" input_field.text = ""
input_field.accept_handler = accept_handler input_field.accept_handler = accept_handler
right_side = HSplit([main_window, input_window])
right_side = HSplit([ def get_popup_text():
main_window, if not client.pending_requests: return []
input_window req_uin = client.pending_requests[0]
]) return [
("class:popup-title", f" Incoming Handshake Request \n"),
("class:popup-title", f" UIN: {req_uin} \n\n"),
("", " Do you want to add them to contacts?\n\n"),
("class:popup-keys", " [Y]es [N]o N[e]ver ")
]
popup_window = Frame(Window(FormattedTextControl(get_popup_text), align=WindowAlign.CENTER, width=42, height=6), style="class:popup-border")
root_container = VSplit([ def get_signal_popup_text():
sidebar_window, if not client.pending_windows: return []
right_side win_info = client.pending_windows[0]
]) w_type = win_info["type"].upper()
w_uin = win_info["uin"]
title_style = "class:alert-title" if w_type == "ALERT" else "class:query-title"
desc = "CRITICAL BREAK-IN! Are you busy?" if w_type == "ALERT" else "Friendly status query: Are you busy?"
return [
(title_style, f" *** INCOMING {w_type} SIGNAL *** \n"),
("class:popup-title", f" From UIN: {w_uin} \n\n"),
("", f" {desc}\n\n"),
("class:popup-keys", " [Y]es, I'm busy [N]o, go on ")
]
signal_popup_window = Frame(Window(FormattedTextControl(get_signal_popup_text), align=WindowAlign.CENTER, width=50, height=6), style="class:border")
def get_welcome_popup_text():
return [
("class:query-title", " *** ДОБРО ПОЖАЛОВАТЬ В TYCHAT! *** \n\n"),
("", " Привет! Вы только что зарегистрировались.\n"),
("", " Хотите автоматически присоединиться\n"),
("", " к всеобщей группе?\n\n"),
("class:popup-keys", " [Y]Да, зайти в Dnishe [N]Нет, я сам ")
]
welcome_popup_window = Frame(Window(FormattedTextControl(get_welcome_popup_text), align=WindowAlign.CENTER, width=48, height=7), style="class:border")
@Condition
def has_pending_request(): return len(client.pending_requests) > 0 and not client.show_welcome_popup
@Condition
def has_pending_window(): return len(client.pending_windows) > 0 and len(client.pending_requests) == 0 and not client.show_welcome_popup
@Condition
def has_welcome_popup(): return client.show_welcome_popup
root_container = FloatContainer(
content=VSplit([sidebar_window, right_side]),
floats=[
Float(content=ConditionalContainer(content=popup_window, filter=has_pending_request), transparent=False),
Float(content=ConditionalContainer(content=signal_popup_window, filter=has_pending_window), transparent=False),
Float(content=ConditionalContainer(content=welcome_popup_window, filter=has_welcome_popup), transparent=False)
]
)
kb = KeyBindings() kb = KeyBindings()
@kb.add('tab') @kb.add('tab', filter=~has_pending_request & ~has_pending_window & ~has_welcome_popup)
def _(event): def _(event):
if event.app.layout.has_focus(input_field): if event.app.layout.has_focus(input_field): event.app.layout.focus(sidebar_window)
event.app.layout.focus(sidebar_window) else: event.app.layout.focus(input_field)
else:
event.app.layout.focus(input_field)
@kb.add('up', filter=has_focus(sidebar_window)) @kb.add('up', filter=has_focus(sidebar_window) & ~has_pending_request & ~has_pending_window & ~has_welcome_popup)
def _(event): def _(event):
if client.contacts: all_chats = list(client.contacts.keys()) + list(client.groups.keys())
client.selected_contact_idx = (client.selected_contact_idx - 1) % len(client.contacts) if all_chats:
target_uin = list(client.contacts.keys())[client.selected_contact_idx] client.selected_contact_idx = (client.selected_contact_idx - 1) % len(all_chats)
client.active_chat = target_uin target = all_chats[client.selected_contact_idx]
client.contacts[target_uin]["unread"] = 0 client.active_chat = target
client.contacts[target_uin]["attention"] = False if target in client.contacts:
client.contacts[target]["unread"] = 0
client.contacts[target]["attention"] = False
client.refresh_ui() client.refresh_ui()
@kb.add('down', filter=has_focus(sidebar_window)) @kb.add('down', filter=has_focus(sidebar_window) & ~has_pending_request & ~has_pending_window & ~has_welcome_popup)
def _(event): def _(event):
if client.contacts: all_chats = list(client.contacts.keys()) + list(client.groups.keys())
client.selected_contact_idx = (client.selected_contact_idx + 1) % len(client.contacts) if all_chats:
target_uin = list(client.contacts.keys())[client.selected_contact_idx] client.selected_contact_idx = (client.selected_contact_idx + 1) % len(all_chats)
client.active_chat = target_uin target = all_chats[client.selected_contact_idx]
client.contacts[target_uin]["unread"] = 0 client.active_chat = target
client.contacts[target_uin]["attention"] = False if target in client.contacts:
client.contacts[target]["unread"] = 0
client.contacts[target]["attention"] = False
client.refresh_ui()
@kb.add('y', filter=has_welcome_popup)
@kb.add('Y', filter=has_welcome_popup)
def _(event):
client.show_welcome_popup = False
r_owner = "716041"
r_name = "Dnishe"
r_id = f"ROOM:{r_owner}:{r_name}"
client.contacts[r_owner] = {"status": "offline", "unread": 0, "attention": False}
client.groups[r_id] = {"title": r_name, "owner": r_owner, "members": [client.uin, r_owner]}
client.history[r_id] = [f"[SYSTEM]: Автоподключение! Комната \"{r_name}\". GUID: {r_id}"]
client.active_chat = r_id
client.save_config()
client.send_sys_packet(r_owner, f"REQ_JOIN_ROOM:{r_name}")
client.refresh_ui()
@kb.add('n', filter=has_welcome_popup)
@kb.add('N', filter=has_welcome_popup)
def _(event):
client.show_welcome_popup = False
client.refresh_ui()
@kb.add('y', filter=has_pending_window)
@kb.add('Y', filter=has_pending_window)
def _(event):
win_info = client.pending_windows.pop(0)
target_uin = win_info["uin"]
client.is_busy = True
client.send_service_tone(target_uin, FREQ_RESP_YES)
client.add_to_history(target_uin, "[You -> SYSTEM]: Sent Dial Tone -> Yes, I'm busy.")
client.refresh_ui()
@kb.add('n', filter=has_pending_window)
@kb.add('N', filter=has_pending_window)
def _(event):
win_info = client.pending_windows.pop(0)
target_uin = win_info["uin"]
client.send_service_tone(target_uin, FREQ_RESP_NO)
client.add_to_history(target_uin, "[You -> SYSTEM]: Sent Dial Tone -> No, go on.")
client.refresh_ui()
@kb.add('y', filter=has_pending_request)
@kb.add('Y', filter=has_pending_request)
def _(event):
req_uin = client.pending_requests.pop(0)
client.contacts[req_uin] = {"status": "online", "unread": 0, "attention": False}
client.history[req_uin] = ["[SYSTEM]: Handshake accepted. Contact added."]
client.active_chat = req_uin
client.save_config()
client.send_sys_packet(req_uin, "RES_ACC")
client.refresh_ui()
@kb.add('n', filter=has_pending_request)
@kb.add('N', filter=has_pending_request)
def _(event):
req_uin = client.pending_requests.pop(0)
client.send_sys_packet(req_uin, "RES_DEC")
client.refresh_ui() client.refresh_ui()
@kb.add('c-c') @kb.add('c-c')
@ -454,6 +751,13 @@ ui_style = Style.from_dict({
'system': '#ff0000 bold', 'system': '#ff0000 bold',
'border': '#00ff00', 'border': '#00ff00',
'frame.border': '#00ff00', 'frame.border': '#00ff00',
'popup-title': '#ffffff bold',
'popup-keys': '#00ff00 bold',
'popup-border': '#ff0000 bold',
'query-title': '#00ffff bold',
'alert-title': '#ff0000 bold',
'completion-menu.completion': 'bg:#00ffff #000000',
'completion-menu.completion.current': 'bg:#00aa00 #ffffff bold'
}) })
def main(): def main():
@ -462,42 +766,37 @@ def main():
try: try:
kernel32 = ctypes.windll.kernel32 kernel32 = ctypes.windll.kernel32
kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)
except: except: pass
pass
is_new_registration = False
if not client.load_config(): if not client.load_config():
print("!" * 60) print("!" * 60)
print("WARNING:") print("WARNING: All data is local.")
print("You will receive new messages only when you are online.")
print("All history is stored locally in settings.json.")
print("Anyone who knows your UIN can message you.")
print("This software uses loud sounds, adjust your client volume beforehand!")
print("!" * 60 + "\n") print("!" * 60 + "\n")
client.server_url = input("Enter server URL (e.g., http://localhost:5000): ").strip() client.server_url = input("Enter server URL: ").strip()
if not client.server_url.startswith("http://") and not client.server_url.startswith("https://"): if not client.server_url.startswith("http://") and not client.server_url.startswith("https://"):
client.server_url = "http://" + client.server_url client.server_url = "http://" + client.server_url
try: try: sio.connect(client.server_url, transports=['websocket'])
sio.connect(client.server_url, transports=['websocket']) except Exception as e: return
except Exception as e:
print(f"Failed to connect to server: {e}")
return
print("\n1. Register\n2. Login") print("\n1. Register\n2. Login")
mode = input("> ") mode = input("> ")
username_or_uin = input("Enter Username (for reg) or UIN (for login): ").strip() username_or_uin = input("UIN/Username: ").strip()
password = input("Enter password: ").strip() password = input("Password: ").strip()
event_wait = threading.Event() event_wait = threading.Event()
@sio.event @sio.event
def register_response(data): def register_response(data):
nonlocal is_new_registration
if data["status"] == "success": if data["status"] == "success":
client.uin = data['uin'] client.uin = data['uin']
client.username = username_or_uin client.username = username_or_uin
client.password = password client.password = password
print(f"\nSuccess! Your UIN: {client.uin}") is_new_registration = True
event_wait.set() event_wait.set()
@sio.event @sio.event
@ -506,32 +805,25 @@ def main():
client.uin = username_or_uin client.uin = username_or_uin
client.username = data["username"] client.username = data["username"]
client.password = password client.password = password
print(f"\nHello, {client.username}! Logged in successfully.")
event_wait.set() event_wait.set()
if mode == "1": if mode == "1": sio.emit("register", {"username": username_or_uin, "password": password})
sio.emit("register", {"username": username_or_uin, "password": password}) else: sio.emit("login", {"uin": username_or_uin, "password": password})
event_wait.wait()
else:
sio.emit("login", {"uin": username_or_uin, "password": password})
event_wait.wait() event_wait.wait()
if not client.uin: if not client.uin: return
print("Auth error.")
return
client.save_config() client.save_config()
print("\nYou can change configurations inside settings.json.")
input("Press Enter to open TUI...") input("Press Enter to open TUI...")
else: else:
try: try:
sio.connect(client.server_url, transports=['websocket']) sio.connect(client.server_url, transports=['websocket'])
sio.emit("login", {"uin": client.uin, "password": client.password}) sio.emit("login", {"uin": client.uin, "password": client.password})
except: except: pass
pass
if is_new_registration:
client.show_welcome_popup = True
threading.Thread(target=status_checker_thread, daemon=True).start() threading.Thread(target=status_checker_thread, daemon=True).start()
layout, bindings = make_layout() layout, bindings = make_layout()
client.app = Application( client.app = Application(
@ -541,7 +833,6 @@ def main():
full_screen=True, full_screen=True,
enable_page_navigation_bindings=True enable_page_navigation_bindings=True
) )
client.app.run() client.app.run()
if __name__ == "__main__": if __name__ == "__main__":