diff --git a/.gitignore b/.gitignore index e38da20..2fda4a6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ settings.json +venv \ No newline at end of file diff --git a/TUI.py b/TUI.py index 10127ae..1249d8c 100644 --- a/TUI.py +++ b/TUI.py @@ -1,5 +1,27 @@ import sys import subprocess +import os +import json +import time +import base64 +import re +import threading +import webbrowser +import importlib +import numpy as np +import sounddevice as sd + +from prompt_toolkit.application import Application +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout.containers import HSplit, VSplit, Window, FloatContainer, Float, ConditionalContainer, WindowAlign +from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.layout.layout import Layout +from prompt_toolkit.widgets import TextArea, Frame +from prompt_toolkit.styles import Style +from prompt_toolkit.application.current import get_app +from prompt_toolkit.filters import has_focus, Condition +from prompt_toolkit.data_structures import Point +from prompt_toolkit.completion import Completer, Completion REQUIRED_PACKAGES = { "python-socketio[client]": "socketio", @@ -31,30 +53,6 @@ def auto_install_deps(): auto_install_deps() -import os -import json -import time -import base64 -import re -import threading -import webbrowser -import numpy as np -import sounddevice as sd - -from prompt_toolkit.application import Application -from prompt_toolkit.key_binding import KeyBindings -from prompt_toolkit.layout.containers import HSplit, VSplit, Window, FloatContainer, Float, ConditionalContainer, WindowAlign -from prompt_toolkit.layout.controls import FormattedTextControl -from prompt_toolkit.layout.layout import Layout -from prompt_toolkit.widgets import TextArea, Frame -from prompt_toolkit.styles import Style -from prompt_toolkit.application.current import get_app -from prompt_toolkit.filters import has_focus, Condition -from prompt_toolkit.data_structures import Point -from prompt_toolkit.completion import Completer, Completion - -# Import decoupled local modules -import protocol import socket_manager CONFIG_FILE = "settings.json" @@ -92,6 +90,64 @@ class CommandCompleter(Completer): if cmd.startswith(text): yield Completion(cmd, start_position=-len(text), display_meta=desc) +class PluginManager: + def __init__(self, client_instance): + self.client = client_instance + self.protocols = {} + self.transports = {} + self.load_plugins() + + def load_plugins(self): + for folder, collection, base_mod in [('protocols', self.protocols, 'protocols'), ('transports', self.transports, 'transports')]: + if not os.path.exists(folder): + os.makedirs(folder) + with open(os.path.join(folder, '__init__.py'), 'w') as f: pass + for f in os.listdir(folder): + if f.endswith('.py') and f != '__init__.py': + mod_name = f[:-3] + try: + mod = importlib.import_module(f"{base_mod}.{mod_name}") + if hasattr(mod, 'Plugin'): + instance = mod.Plugin() + collection[instance.name] = {"instance": instance, "enabled": True, "failed": False} + except: + pass + + def safe_encode(self, proto_name, text): + proto = self.protocols.get(proto_name) + if not proto or not proto["enabled"] or proto["failed"]: + return None + try: + return proto["instance"].encode(text) + except: + proto["failed"] = True + proto["enabled"] = False + self.client.add_to_history(self.client.active_chat or "SYSTEM", f"[SYSTEM]: Protocol '{proto_name}' crashed and was disabled.") + return None + + def safe_decode(self, proto_name, data): + proto = self.protocols.get(proto_name) + if not proto or not proto["enabled"] or proto["failed"]: + return None + try: + return proto["instance"].decode(data) + except: + proto["failed"] = True + proto["enabled"] = False + self.client.add_to_history(self.client.active_chat or "SYSTEM", f"[SYSTEM]: Protocol '{proto_name}' crashed and was disabled.") + return None + + def safe_generate_service(self, proto_name, sig_type): + proto = self.protocols.get(proto_name) + if not proto or not proto["enabled"] or proto["failed"]: + return None + try: + return proto["instance"].generate_service_signal(sig_type) + except: + proto["failed"] = True + proto["enabled"] = False + return None + class TyClient: def __init__(self): self.server_url = "" @@ -114,6 +170,15 @@ class TyClient: self.pending_windows = [] self.show_welcome_popup = False + self.current_tab = "chat" + self.settings_cursor = 0 + self.settings_section = "protocols" + self.primary_protocol = "" + self.primary_transport = "" + self.peer_session_protocols = {} + + self.plugin_manager = PluginManager(self) + def load_config(self): if os.path.exists(CONFIG_FILE): try: @@ -128,12 +193,32 @@ class TyClient: self.history = cfg.get("history", {}) self.preserved = cfg.get("preserved", {}) self.blocklist = cfg.get("blocklist", []) + + self.primary_protocol = cfg.get("primary_protocol", "") + self.primary_transport = cfg.get("primary_transport", "") + + saved_protos = cfg.get("enabled_protocols", {}) + for p_name, state in saved_protos.items(): + if p_name in self.plugin_manager.protocols: + self.plugin_manager.protocols[p_name]["enabled"] = state + + saved_trans = cfg.get("enabled_transports", {}) + for t_name, state in saved_trans.items(): + if t_name in self.plugin_manager.transports: + self.plugin_manager.transports[t_name]["enabled"] = state + + if not self.primary_protocol and self.plugin_manager.protocols: + self.primary_protocol = list(self.plugin_manager.protocols.keys())[0] + if not self.primary_transport and self.plugin_manager.transports: + self.primary_transport = list(self.plugin_manager.transports.keys())[0] return True except: return False return False def save_config(self): + enabled_protos = {k: v["enabled"] for k, v in self.plugin_manager.protocols.items()} + enabled_trans = {k: v["enabled"] for k, v in self.plugin_manager.transports.items()} cfg = { "server_url": self.server_url, "username": self.username, @@ -143,7 +228,11 @@ class TyClient: "groups": self.groups, "history": self.history, "preserved": self.preserved, - "blocklist": self.blocklist + "blocklist": self.blocklist, + "primary_protocol": self.primary_protocol, + "primary_transport": self.primary_transport, + "enabled_protocols": enabled_protos, + "enabled_transports": enabled_trans } with open(CONFIG_FILE, "w", encoding="utf-8") as f: json.dump(cfg, f, indent=4, ensure_ascii=False) @@ -151,9 +240,9 @@ class TyClient: 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(protocol.SAMPLE_RATE * 1.0), False) + t = np.linspace(0, 1.0, int(44100 * 1.0), False) tone = np.sin(425 * t * 2 * np.pi) * 0.5 - sd.play(tone, protocol.SAMPLE_RATE) + sd.play(tone, 44100) sd.wait() for _ in range(30): if self.dialing_uin != target_uin or not self.loop_running: @@ -162,10 +251,10 @@ class TyClient: def play_and_decode(self, audio_bytes, sender_uin): audio_data = np.frombuffer(audio_bytes, dtype=np.float32) - sd.play(audio_data, protocol.SAMPLE_RATE) + sd.play(audio_data, 44100) time.sleep(0.3) - samples_per_tone = int(protocol.SAMPLE_RATE * protocol.TONE_DURATION) - current_sample = int(protocol.SAMPLE_RATE * 0.3) + samples_per_tone = int(44100 * 0.1) + current_sample = int(44100 * 0.3) sender_name = self.username if sender_uin == self.uin else f"UIN {sender_uin}" msg_buffer = f"[{sender_name}]: " @@ -181,9 +270,9 @@ class TyClient: 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/protocol.SAMPLE_RATE) + frequencies = np.fft.rfftfreq(len(chunk), d=1/44100) detected_freq = frequencies[np.argmax(fft_data)] - ascii_code = int(round((detected_freq - protocol.BASE_FREQ) / protocol.FREQ_STEP)) + ascii_code = int(round((detected_freq - 600) / 25)) if 0 <= ascii_code < 65535: try: char = chr(ascii_code) @@ -191,7 +280,7 @@ class TyClient: self.refresh_ui() except: pass - time.sleep(protocol.TONE_DURATION) + time.sleep(0.1) current_sample += samples_per_tone sd.wait() self.save_config() @@ -204,12 +293,38 @@ class TyClient: self.save_config() def send_sys_packet(self, to_uin, cmd): - audio_b = protocol.text_to_audio(f"SYS:{cmd}") - socket_manager.sio.emit("relay_packet", {"to_uin": to_uin, "payload": base64.b64encode(audio_b).decode('utf-8')}) + text_payload = f"SYS:{cmd}" + proto_order = [] + if self.primary_protocol: + proto_order.append(self.primary_protocol) + for p in self.plugin_manager.protocols: + if p not in proto_order: + proto_order.append(p) - def send_service_tone(self, to_uin, freq): - audio_b = protocol.generate_service_tone(freq) - socket_manager.sio.emit("relay_packet", {"to_uin": to_uin, "payload": base64.b64encode(audio_b).decode('utf-8')}) + for p_name in proto_order: + encoded = self.plugin_manager.safe_encode(p_name, text_payload) + if encoded is not None: + wrapped = f"NATIVE:{p_name}:".encode('utf-8') + encoded + socket_manager.sio.emit("relay_packet", {"to_uin": to_uin, "payload": base64.b64encode(wrapped).decode('utf-8')}) + return + + def send_service_tone(self, to_uin, freq_or_type): + sig_map = {1200: "QUERY", 1400: "ALERT", 1600: "RESP_YES", 1800: "RESP_NO"} + sig_type = sig_map.get(freq_or_type, str(freq_or_type)) + + proto_order = [] + if self.primary_protocol: + proto_order.append(self.primary_protocol) + for p in self.plugin_manager.protocols: + if p not in proto_order: + proto_order.append(p) + + for p_name in proto_order: + encoded = self.plugin_manager.safe_generate_service(p_name, sig_type) + if encoded is not None: + wrapped = f"SERVICE:{p_name}:".encode('utf-8') + encoded + socket_manager.sio.emit("relay_packet", {"to_uin": to_uin, "payload": base64.b64encode(wrapped).decode('utf-8')}) + return def refresh_ui(self): if self.app: @@ -225,10 +340,28 @@ def status_checker_thread(): if client.contacts[uin]["status"] == "online" and client.preserved.get(uin): while client.preserved[uin]: p_text = client.preserved[uin].pop(0) - audio_b = protocol.text_to_audio(p_text) - p_b64 = base64.b64encode(audio_b).decode('utf-8') - socket_manager.sio.emit("relay_packet", {"to_uin": uin, "payload": p_b64}) - client.add_to_history(uin, f"[You]: {p_text}") + + proto_to_use = client.peer_session_protocols.get(uin, client.primary_protocol) + encoded = client.plugin_manager.safe_encode(proto_to_use, p_text) + + if encoded is None: + proto_order = [p for p in client.plugin_manager.protocols if p != proto_to_use] + for p_name in proto_order: + encoded = client.plugin_manager.safe_encode(p_name, p_text) + if encoded is not None: + proto_to_use = p_name + client.peer_session_protocols[uin] = p_name + break + + if encoded is not None: + wrapped = f"NATIVE:{proto_to_use}:".encode('utf-8') + encoded + p_b64 = base64.b64encode(wrapped).decode('utf-8') + socket_manager.sio.emit("relay_packet", {"to_uin": uin, "payload": p_b64}) + client.add_to_history(uin, f"[You]: {p_text}") + else: + client.preserved[uin].insert(0, p_text) + client.add_to_history(uin, "[SYSTEM]: Failed to negotiate protocol. Stalling output.") + break client.save_config() time.sleep(10) @@ -241,14 +374,14 @@ def make_layout(): elif info.get("unread", 0) > 0: status_str = f" ({info['unread']})" elif info.get("status") == "online": 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" + if client.active_chat == uin and client.current_tab == "chat": style = "class:contact-active" + elif idx == client.selected_contact_idx and get_app().layout.has_focus(sidebar_window) and client.current_tab == "chat": style = "class:contact-focused" else: style = "class:contact" tokens.extend([(style, f"{content}\n")]) for g_id, g_info in client.groups.items(): content = f" [G] {g_info['title']}" - if client.active_chat == g_id: style = "class:contact-active" + if client.active_chat == g_id and client.current_tab == "chat": style = "class:contact-active" else: style = "class:contact" tokens.extend([(style, f"{content}\n")]) return tokens @@ -263,7 +396,7 @@ def make_layout(): ("class:desc", "Messenger on custom protocol named\n"), ("class:desc", "AcoustiOverSocket inspired by rtty\n"), ("", "\n"), - ("class:help-tip", "Type / to trigger interactive command menu\n"), + ("class:help-tip", "Type / to trigger interactive command menu | F2: Settings Panel\n"), ("", "\n" * 2), ("class:help-tip", f"{DEV_SIGNATURE}\n") ]) @@ -285,6 +418,45 @@ def make_layout(): tokens.append(("", line + "\n")) return tokens + def get_settings_text(): + tokens = [("class:popup-title", " === SETTINGS & PLUGINS PANEL ===\n\n")] + + proto_style = "class:contact-focused" if client.settings_section == "protocols" else "class:contact" + trans_style = "class:contact-focused" if client.settings_section == "transports" else "class:contact" + + tokens.append((proto_style, " [ PROTOCOLS ] ")) + tokens.append(("", " ")) + tokens.append((trans_style, " [ TRANSPORTS ] \n\n")) + + collection = client.plugin_manager.protocols if client.settings_section == "protocols" else client.plugin_manager.transports + primary = client.primary_protocol if client.settings_section == "protocols" else client.primary_transport + + items = list(collection.keys()) + if not items: + tokens.append(("", " No items found in this section.\n")) + else: + for idx, name in enumerate(items): + info = collection[name] + marker = "[X]" if info["enabled"] else "[ ]" + prim_marker = " (Primary)" if name == primary else "" + fail_marker = " [CRASHED/DISABLED]" if info["failed"] else "" + + if idx == client.settings_cursor: + style = "class:contact-active" + item_str = f" > {marker} {name}{prim_marker}{fail_marker}\n" + else: + style = "class:contact" + item_str = f" {marker} {name}{prim_marker}{fail_marker}\n" + tokens.append((style, item_str)) + + tokens.append(("", "\n--- Controls ---\n")) + tokens.append(("class:help-tip", " Left/Right: Switch sections\n")) + tokens.append(("class:help-tip", " Up/Down: Select item\n")) + tokens.append(("class:help-tip", " Space: Toggle Enable/Disable\n")) + tokens.append(("class:help-tip", " P: Set chosen item as Primary\n")) + tokens.append(("class:help-tip", " F2: Return back to Chat Panel\n")) + return tokens + def get_cursor_pos(): text = "".join(t[1] for t in get_main_text()) newlines = text.count('\n') @@ -302,6 +474,9 @@ def make_layout(): main_window = Frame(Window(content=main_control, wrap_lines=True), title=get_main_title, style="class:border") + settings_control = FormattedTextControl(get_settings_text, focusable=True) + settings_window = Frame(Window(content=settings_control, wrap_lines=True), title="Settings Router", style="class:border") + input_field = TextArea( height=3, prompt="> ", @@ -390,26 +565,39 @@ def make_layout(): client.add_to_history(client.active_chat, "[SYSTEM]: CANNOT SEND. Owner of this room is offline for sync!") input_field.text = "" return - audio_b = protocol.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": - socket_manager.sio.emit("relay_packet", {"to_uin": m, "payload": p_b64}) + + proto_to_use = client.primary_protocol + encoded = client.plugin_manager.safe_encode(proto_to_use, f"SYS:ROOM_MSG:{client.active_chat}:{client.uin}:{text}") + if encoded is None: + for p in client.plugin_manager.protocols: + encoded = client.plugin_manager.safe_encode(p, f"SYS:ROOM_MSG:{client.active_chat}:{client.uin}:{text}") + if encoded is not None: + proto_to_use = p + break + + if encoded is not None: + wrapped = f"NATIVE:{proto_to_use}:".encode('utf-8') + encoded + p_b64 = base64.b64encode(wrapped).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": + socket_manager.sio.emit("relay_packet", {"to_uin": m, "payload": p_b64}) + else: + socket_manager.sio.emit("relay_packet", {"to_uin": owner_uin, "payload": p_b64}) + client.add_to_history(client.active_chat, f"[You]: {text}") else: - socket_manager.sio.emit("relay_packet", {"to_uin": owner_uin, "payload": p_b64}) - client.add_to_history(client.active_chat, f"[You]: {text}") + client.add_to_history(client.active_chat, "[SYSTEM]: Local engine failure encoding text frame.") input_field.text = "" return if text.lower() == "/query": - client.send_service_tone(client.active_chat, protocol.FREQ_QUERY) + client.send_service_tone(client.active_chat, 1200) 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, protocol.FREQ_ALERT) - client.add_to_history(client.active_chat, "[SYSTEM]: Sent URGENT alert signal (breaks DND)...") + client.send_service_tone(client.active_chat, 1400) + client.add_to_history(client.active_chat, "[SYSTEM]: Sent URGENT alert signal...") input_field.text = "" return if len(text) > 300: @@ -417,20 +605,48 @@ def make_layout(): input_field.text = "" return - audio_b = protocol.text_to_audio(text) - p_b64 = base64.b64encode(audio_b).decode('utf-8') - if client.contacts[client.active_chat]["status"] == "online": - socket_manager.sio.emit("relay_packet", {"to_uin": client.active_chat, "payload": p_b64}) - client.add_to_history(client.active_chat, f"[You]: {text}") + proto_to_use = client.peer_session_protocols.get(client.active_chat, client.primary_protocol) + encoded = client.plugin_manager.safe_encode(proto_to_use, text) + + if encoded is None: + proto_order = [p for p in client.plugin_manager.protocols if p != proto_to_use] + for p_name in proto_order: + encoded = client.plugin_manager.safe_encode(p_name, text) + if encoded is not None: + proto_to_use = p_name + client.peer_session_protocols[client.active_chat] = p_name + break + + if encoded is not None: + wrapped = f"NATIVE:{proto_to_use}:".encode('utf-8') + encoded + p_b64 = base64.b64encode(wrapped).decode('utf-8') + if client.contacts[client.active_chat]["status"] == "online": + socket_manager.sio.emit("relay_packet", {"to_uin": client.active_chat, "payload": p_b64}) + client.add_to_history(client.active_chat, f"[You]: {text}") + else: + if client.active_chat not in client.preserved: client.preserved[client.active_chat] = [] + client.preserved[client.active_chat].append(text) + client.add_to_history(client.active_chat, f"[You]: {text} (Preserved)") + client.save_config() else: - if client.active_chat not in client.preserved: client.preserved[client.active_chat] = [] - client.preserved[client.active_chat].append(text) - client.add_to_history(client.active_chat, f"[You]: {text} (Preserved)") - client.save_config() + client.add_to_history(client.active_chat, "[SYSTEM]: Conversation pipeline failed. No working/enabled matching protocols.") input_field.text = "" input_field.accept_handler = accept_handler - right_side = HSplit([main_window, input_window]) + + @Condition + def is_chat_panel(): + return client.current_tab == "chat" + + @Condition + def is_settings_panel(): + return client.current_tab == "settings" + + right_display = HSplit([ + ConditionalContainer(content=main_window, filter=is_chat_panel), + ConditionalContainer(content=settings_window, filter=is_settings_panel), + input_window + ]) def get_popup_text(): if not client.pending_requests: return [] @@ -476,7 +692,7 @@ def make_layout(): def has_welcome_popup(): return client.show_welcome_popup root_container = FloatContainer( - content=VSplit([sidebar_window, right_side]), + content=VSplit([sidebar_window, right_display]), 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), @@ -486,12 +702,76 @@ def make_layout(): kb = KeyBindings() - @kb.add('tab', filter=~has_pending_request & ~has_pending_window & ~has_welcome_popup) + @kb.add('f2') + def _(event): + if client.current_tab == "chat": + client.current_tab = "settings" + client.settings_cursor = 0 + else: + client.current_tab = "chat" + client.refresh_ui() + + @kb.add('left', filter=is_settings_panel) + def _(event): + if client.settings_section == "transports": + client.settings_section = "protocols" + client.settings_cursor = 0 + client.refresh_ui() + + @kb.add('right', filter=is_settings_panel) + def _(event): + if client.settings_section == "protocols": + client.settings_section = "transports" + client.settings_cursor = 0 + client.refresh_ui() + + @kb.add('up', filter=is_settings_panel) + def _(event): + collection = client.plugin_manager.protocols if client.settings_section == "protocols" else client.plugin_manager.transports + items = list(collection.keys()) + if items: + client.settings_cursor = (client.settings_cursor - 1) % len(items) + client.refresh_ui() + + @kb.add('down', filter=is_settings_panel) + def _(event): + collection = client.plugin_manager.protocols if client.settings_section == "protocols" else client.plugin_manager.transports + items = list(collection.keys()) + if items: + client.settings_cursor = (client.settings_cursor + 1) % len(items) + client.refresh_ui() + + @kb.add('space', filter=is_settings_panel) + def _(event): + collection = client.plugin_manager.protocols if client.settings_section == "protocols" else client.plugin_manager.transports + items = list(collection.keys()) + if items: + name = items[client.settings_cursor] + collection[name]["enabled"] = not collection[name]["enabled"] + client.save_config() + client.refresh_ui() + + @kb.add('p', filter=is_settings_panel) + @kb.add('P', filter=is_settings_panel) + def _(event): + collection = client.plugin_manager.protocols if client.settings_section == "protocols" else client.plugin_manager.transports + items = list(collection.keys()) + if items: + name = items[client.settings_cursor] + if collection[name]["enabled"]: + if client.settings_section == "protocols": + client.primary_protocol = name + else: + client.primary_transport = name + client.save_config() + client.refresh_ui() + + @kb.add('tab', filter=~has_pending_request & ~has_pending_window & ~has_welcome_popup & is_chat_panel) def _(event): if event.app.layout.has_focus(input_field): event.app.layout.focus(sidebar_window) else: event.app.layout.focus(input_field) - @kb.add('up', filter=has_focus(sidebar_window) & ~has_pending_request & ~has_pending_window & ~has_welcome_popup) + @kb.add('up', filter=has_focus(sidebar_window) & ~has_pending_request & ~has_pending_window & ~has_welcome_popup & is_chat_panel) def _(event): all_chats = list(client.contacts.keys()) + list(client.groups.keys()) if all_chats: @@ -503,7 +783,7 @@ def make_layout(): client.contacts[target]["attention"] = False client.refresh_ui() - @kb.add('down', filter=has_focus(sidebar_window) & ~has_pending_request & ~has_pending_window & ~has_welcome_popup) + @kb.add('down', filter=has_focus(sidebar_window) & ~has_pending_request & ~has_pending_window & ~has_welcome_popup & is_chat_panel) def _(event): all_chats = list(client.contacts.keys()) + list(client.groups.keys()) if all_chats: @@ -542,7 +822,7 @@ def make_layout(): win_info = client.pending_windows.pop(0) target_uin = win_info["uin"] client.is_busy = True - client.send_service_tone(target_uin, protocol.FREQ_RESP_YES) + client.send_service_tone(target_uin, 1600) client.add_to_history(target_uin, "[You -> SYSTEM]: Sent Dial Tone -> Yes, I'm busy.") client.refresh_ui() @@ -551,7 +831,7 @@ def make_layout(): def _(event): win_info = client.pending_windows.pop(0) target_uin = win_info["uin"] - client.send_service_tone(target_uin, protocol.FREQ_RESP_NO) + client.send_service_tone(target_uin, 1800) client.add_to_history(target_uin, "[You -> SYSTEM]: Sent Dial Tone -> No, go on.") client.refresh_ui() @@ -610,8 +890,6 @@ def main(): except: pass is_new_registration = False - - # Initialize the cross-module link so network callbacks can manipulate local state socket_manager.init_network(client) if not client.load_config(): diff --git a/compile.py b/compile.py deleted file mode 100644 index b8e9022..0000000 --- a/compile.py +++ /dev/null @@ -1,40 +0,0 @@ -import os -import subprocess -import sys -import shutil -import platform - -REPO_URL = "https://git.idkmail.ru/lohrrrr/TyChat-Client.git" -PROJECT_DIR = "TyChat-Client-Build" -VENV_DIR = os.path.join(PROJECT_DIR, "venv") -ENTRY_POINT = "TUI.py" - -def run_cmd(cmd, cwd=None): - subprocess.run(cmd, cwd=cwd, check=True) - -def setup_build(): - if os.path.exists(PROJECT_DIR): - shutil.rmtree(PROJECT_DIR) - print(f"[*] Cloning repository...") - run_cmd(["git", "clone", REPO_URL, PROJECT_DIR]) - - print(f"[*] Creating virtual environment...") - run_cmd([sys.executable, "-m", "venv", VENV_DIR]) - - if platform.system() == "Windows": - pip_path = os.path.join(VENV_DIR, "Scripts", "pip") - pyinstaller_path = os.path.join(VENV_DIR, "Scripts", "pyinstaller") - else: - pip_path = os.path.join(VENV_DIR, "bin", "pip") - pyinstaller_path = os.path.join(VENV_DIR, "bin", "pyinstaller") - - print(f"[*] Installing dependencies...") - run_cmd([pip_path, "install", "pyinstaller", "socketio", "websocket-client", "numpy", "sounddevice", "prompt_toolkit"]) - - print(f"[*] Building standalone binary...") - run_cmd([pyinstaller_path, "--onefile", "--name", "TyChat", ENTRY_POINT], cwd=PROJECT_DIR) - - print(f"\n[!] Build Complete! Find your binary in: {os.path.join(PROJECT_DIR, 'dist')}") - -if __name__ == "__main__": - setup_build() \ No newline at end of file diff --git a/extra/compile.py b/extra/compile.py new file mode 100644 index 0000000..4f8bd35 --- /dev/null +++ b/extra/compile.py @@ -0,0 +1,124 @@ +# TyChat TUI auto installer +# This is bugged pice of shit because of crossplatform +# I will only officialy supporting Windows and UNIX-Like systems excluding MacOS and KolibriOS + +import os +import sys +import shutil +import subprocess +import platform +import urllib.request +import zipfile + +# Configuration, you can change if you like, for example, making a fork of my messenger +REPO_URL = "https://git.idkmail.ru/lohrrrr/TyChat-Client.git" +PROJECT_DIR = "TyChat-Client-Build" +VENV_DIR = os.path.join(PROJECT_DIR, "setup-venv") +ENTRY_POINT = "TUI.py" +DEPENDENCIES = [ + "pyinstaller", + "python-socketio", + "websocket-client", + "numpy", + "sounddevice", + "prompt_toolkit" +] + +GREEN = '\033[92m' +RESET = '\033[0m' + +def run_command(command, cwd=None, shell=False): + """Wrapper to run shell commands.""" + try: + subprocess.run(command, cwd=cwd, check=True, shell=shell) + except subprocess.CalledProcessError as e: + print(f"[ERROR] Command failed: {' '.join(command)}") + sys.exit(1) + +def get_venv_paths(): + """Determine paths based on the operating system and check for valid executables.""" + # Resolve to an absolute path so altering cwd downstream doesn't break relative lookups + absolute_venv = os.path.abspath(VENV_DIR) + + if platform.system() == "Windows": + return os.path.join(absolute_venv, "Scripts", "python.exe"), os.path.join(absolute_venv, "Scripts", "pip.exe") + + # Check for python3, fallback to python + python_path = os.path.join(absolute_venv, "bin", "python3") + if not os.path.exists(python_path): + python_path = os.path.join(absolute_venv, "bin", "python") + + # Check for pip3, fallback to pip + pip_path = os.path.join(absolute_venv, "bin", "pip3") + if not os.path.exists(pip_path): + pip_path = os.path.join(absolute_venv, "bin", "pip") + + return python_path, pip_path + +def download_and_extract_zip(url, target_dir): + """Downloads the repository source ZIP and extracts its contents into target_dir.""" + zip_path = "repo_archive.zip" + try: + print(" -> Git not found. Downloading source ZIP archive...") + # Download the zip file + urllib.request.urlretrieve(url, zip_path) + + print(" -> Extracting archive...") + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + # The archive extracts into a root folder like 'TyChat-Client' or 'TyChat-Client-main' + # We look at the first directory level inside the ZIP + root_in_zip = zip_ref.namelist()[0].split('/')[0] + zip_ref.extractall(".") + + # Rename the extracted folder to match your expected PROJECT_DIR + if os.path.exists(root_in_zip): + os.rename(root_in_zip, target_dir) + + except Exception as e: + print(f"[ERROR] Failed to download or extract the repository ZIP: {e}") + sys.exit(1) + finally: + # Clean up the downloaded zip file + if os.path.exists(zip_path): + os.remove(zip_path) + +def main(): + # Cleanup existing build + if os.path.exists(PROJECT_DIR): + shutil.rmtree(PROJECT_DIR) + + print("[*] Cloning repository...") + # Check if git is available in the system PATH + if shutil.which("git"): + run_command(["git", "clone", REPO_URL, PROJECT_DIR]) + else: + # Fallback to the direct zip archive if git is missing + zip_url = "https://git.idkmail.ru/lohrrrr/TyChat-Client/archive/main.zip" + download_and_extract_zip(zip_url, PROJECT_DIR) + + print("[*] Creating virtual environment...") + run_command([sys.executable, "-m", "venv", VENV_DIR]) + + python_exe, pip_exe = get_venv_paths() + + print("[*] Upgrading pip...") + run_command([python_exe, "-m", "pip", "install", "--upgrade", "pip"]) + + print("[*] Installing dependencies...") + for dep in DEPENDENCIES: + print(f" -> Installing {dep}...") + run_command([pip_exe, "install", dep]) + + print("[*] Building standalone binary...") + # Run PyInstaller (and pray to the god) + run_command([python_exe, "-m", "PyInstaller", "--onefile", "--name", "TyChat", ENTRY_POINT], cwd=PROJECT_DIR) + + dist_dir = os.path.join(PROJECT_DIR, "dist") + + if os.path.exists(dist_dir): + print(f"\n{GREEN}Your executable file is located in {os.path.abspath(dist_dir)}{RESET}") + else: + print(f"\n[!] Build complete, but 'dist' directory not found at: {dist_dir}.") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/protocol.py b/protocol.py index fa07b06..22f4c2e 100644 --- a/protocol.py +++ b/protocol.py @@ -10,43 +10,25 @@ FREQ_ALERT = 1400 FREQ_RESP_YES = 1600 FREQ_RESP_NO = 1800 -def generate_service_tone(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() +class Plugin: + def __init__(self): + self.name = "Audio Protocol" -def text_to_audio(text): - audio_signals = [] - t_start = np.linspace(0, 0.3, int(SAMPLE_RATE * 0.3), False) - audio_signals.append(np.sin(1000 * t_start * 2 * np.pi)) - for char in text: - freq = BASE_FREQ + ord(char) * FREQ_STEP - t = np.linspace(0, TONE_DURATION, int(SAMPLE_RATE * TONE_DURATION), False) - audio_signals.append(np.sin(freq * t * 2 * np.pi)) - full_audio = np.concatenate(audio_signals).astype(np.float32) - if np.max(np.abs(full_audio)) > 0: - full_audio = full_audio / np.max(np.abs(full_audio)) - return full_audio.tobytes() + def encode(self, text: str) -> bytes: + audio_signals = [] + t_start = np.linspace(0, 0.3, int(SAMPLE_RATE * 0.3), False) + audio_signals.append(np.sin(1000 * t_start * 2 * np.pi)) + for char in text: + freq = BASE_FREQ + ord(char) * FREQ_STEP + t = np.linspace(0, TONE_DURATION, int(SAMPLE_RATE * TONE_DURATION), False) + audio_signals.append(np.sin(freq * t * 2 * np.pi)) + full_audio = np.concatenate(audio_signals).astype(np.float32) + if np.max(np.abs(full_audio)) > 0: + full_audio = full_audio / np.max(np.abs(full_audio)) + return full_audio.tobytes() -def detect_service_tone(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(audio_data): - try: + def decode(self, data: bytes) -> str: + audio_data = np.frombuffer(data, dtype=np.float32) samples_per_tone = int(SAMPLE_RATE * TONE_DURATION) current_sample = int(SAMPLE_RATE * 0.3) text = "" @@ -63,5 +45,52 @@ def fast_decode(audio_data): text += chr(ascii_code) current_sample += samples_per_tone return text - except: - return "" \ No newline at end of file + + def generate_service_signal(self, signal_type: str) -> bytes: + sig_map = {"QUERY": FREQ_QUERY, "ALERT": FREQ_ALERT, "RESP_YES": FREQ_RESP_YES, "RESP_NO": FREQ_RESP_NO} + freq = sig_map.get(signal_type, FREQ_QUERY) + t = np.linspace(0, 0.4, int(SAMPLE_RATE * 0.4), False) + tone = np.sin(freq * 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 detect_service_signal(self, data: bytes) -> str: + try: + audio_data = np.frombuffer(data, dtype=np.float32) + 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)] + sig_map = {FREQ_QUERY: "QUERY", FREQ_ALERT: "ALERT", FREQ_RESP_YES: "RESP_YES", FREQ_RESP_NO: "RESP_NO"} + for target_freq, sig_type in sig_map.items(): + if abs(detected_freq - target_freq) <= 15: + return sig_type + return None + except: + return None + +def generate_service_tone(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(text): + p = Plugin() + return p.encode(text) + +def detect_service_tone(audio_data): + p = Plugin() + sig = p.detect_service_signal(audio_data.tobytes()) + sig_map = {"QUERY": FREQ_QUERY, "ALERT": FREQ_ALERT, "RESP_YES": FREQ_RESP_YES, "RESP_NO": FREQ_RESP_NO} + return sig_map.get(sig, None) + +def fast_decode(audio_data): + p = Plugin() + return p.decode(audio_data.tobytes()) \ No newline at end of file diff --git a/protocols/__init__.py b/protocols/__init__.py new file mode 100644 index 0000000..27dc5b6 --- /dev/null +++ b/protocols/__init__.py @@ -0,0 +1,24 @@ +import os +import importlib +import logging + +class BaseProtocol: + name = "Base" + def encode(self, text: str) -> bytes: raise NotImplementedError + def decode(self, data: bytes) -> str: raise NotImplementedError + def generate_service_signal(self, signal_type: str) -> bytes: raise NotImplementedError + def detect_service_signal(self, data: bytes) -> str: raise NotImplementedError + +def load_protocols(): + plugins = {} + for f in os.listdir('./protocols'): + if f.endswith('.py') and f != '__init__.py': + mod_name = f[:-3] + try: + mod = importlib.import_module(f'protocols.{mod_name}') + if hasattr(mod, 'ProtocolPlugin'): + instance = mod.ProtocolPlugin() + plugins[instance.name] = {"instance": instance, "enabled": True, "failed": False} + except Exception as e: + logging.error(f"Failed to load protocol {mod_name}: {e}") + return plugins \ No newline at end of file diff --git a/protocols/audio_protocol.py b/protocols/audio_protocol.py new file mode 100644 index 0000000..1e94b95 --- /dev/null +++ b/protocols/audio_protocol.py @@ -0,0 +1,73 @@ +import numpy as np + +BASE_FREQ = 600 +FREQ_STEP = 25 +TONE_DURATION = 0.1 +SAMPLE_RATE = 44100 + +FREQ_QUERY = 1200 +FREQ_ALERT = 1400 +FREQ_RESP_YES = 1600 +FREQ_RESP_NO = 1800 + +class Plugin: + def __init__(self): + self.name = "Audio Protocol" + + def encode(self, text: str) -> bytes: + audio_signals = [] + t_start = np.linspace(0, 0.3, int(SAMPLE_RATE * 0.3), False) + audio_signals.append(np.sin(1000 * t_start * 2 * np.pi)) + for char in text: + freq = BASE_FREQ + ord(char) * FREQ_STEP + t = np.linspace(0, TONE_DURATION, int(SAMPLE_RATE * TONE_DURATION), False) + audio_signals.append(np.sin(freq * t * 2 * np.pi)) + full_audio = np.concatenate(audio_signals).astype(np.float32) + if np.max(np.abs(full_audio)) > 0: + full_audio = full_audio / np.max(np.abs(full_audio)) + return full_audio.tobytes() + + def decode(self, data: bytes) -> str: + audio_data = np.frombuffer(data, dtype=np.float32) + 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 + + def generate_service_signal(self, signal_type: str) -> bytes: + sig_map = {"QUERY": FREQ_QUERY, "ALERT": FREQ_ALERT, "RESP_YES": FREQ_RESP_YES, "RESP_NO": FREQ_RESP_NO} + freq = sig_map.get(signal_type, FREQ_QUERY) + t = np.linspace(0, 0.4, int(SAMPLE_RATE * 0.4), False) + tone = np.sin(freq * 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 detect_service_signal(self, data: bytes) -> str: + try: + audio_data = np.frombuffer(data, dtype=np.float32) + 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)] + sig_map = {FREQ_QUERY: "QUERY", FREQ_ALERT: "ALERT", FREQ_RESP_YES: "RESP_YES", FREQ_RESP_NO: "RESP_NO"} + for target_freq, sig_type in sig_map.items(): + if abs(detected_freq - target_freq) <= 15: + return sig_type + return None + except: + return None \ No newline at end of file diff --git a/protocols/text_protocol.py b/protocols/text_protocol.py new file mode 100644 index 0000000..6912789 --- /dev/null +++ b/protocols/text_protocol.py @@ -0,0 +1,21 @@ +class Plugin: + def __init__(self): + self.name = "Raw Text Protocol" + + def encode(self, text: str) -> bytes: + return text.encode('utf-8') + + def decode(self, data: bytes) -> str: + return data.decode('utf-8') + + def generate_service_signal(self, signal_type: str) -> bytes: + return f"SIG:{signal_type}".encode('utf-8') + + def detect_service_signal(self, data: bytes) -> str: + try: + decoded = data.decode('utf-8') + if decoded.startswith("SIG:"): + return decoded.split(":", 1)[1] + return None + except: + return None \ No newline at end of file diff --git a/socket_manager.py b/socket_manager.py index 7bd5733..425a391 100644 --- a/socket_manager.py +++ b/socket_manager.py @@ -3,13 +3,11 @@ import base64 import numpy as np import threading import sounddevice as sd -import protocol sio = socketio.Client() _client_instance = None def init_network(client_instance): - """Binds the network events to the active TUI client instance state.""" global _client_instance _client_instance = client_instance @@ -19,76 +17,118 @@ def incoming_packet(data): from_uin = data["from_uin"] payload_base64 = data["payload"] try: - audio_bytes = base64.b64decode(payload_base64.encode('utf-8')) - audio_data = np.frombuffer(audio_bytes, dtype=np.float32) + raw_bytes = base64.b64decode(payload_base64.encode('utf-8')) - srv_tone = protocol.detect_service_tone(audio_data) - if srv_tone: - sd.play(audio_data, protocol.SAMPLE_RATE) - if from_uin not in _client_instance.contacts: - return - if srv_tone == protocol.FREQ_QUERY: - if _client_instance.is_busy: - _client_instance.send_service_tone(from_uin, protocol.FREQ_RESP_YES) - else: - _client_instance.pending_windows.append({"type": "query", "uin": from_uin}) - _client_instance.refresh_ui() - return - elif srv_tone == protocol.FREQ_ALERT: - _client_instance.pending_windows.append({"type": "alert", "uin": from_uin}) - _client_instance.refresh_ui() - return - elif srv_tone == protocol.FREQ_RESP_YES: - _client_instance.add_to_history(from_uin, f"[SYSTEM]: Quick status answer -> Yes, I'm busy") - return - elif srv_tone == protocol.FREQ_RESP_NO: - _client_instance.add_to_history(from_uin, f"[SYSTEM]: Quick status answer -> No, go on") - return - - fast_text = protocol.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_instance.groups: - _client_instance.add_to_history(room_id, f"[UIN {sender_uin}]: {actual_msg}") - return - elif cmd == "REQ_ADD": - if from_uin in _client_instance.blocklist: - _client_instance.send_sys_packet(from_uin, "RES_DEC") - elif _client_instance.is_busy: - _client_instance.send_sys_packet(from_uin, "RES_BSY") - else: - if from_uin not in _client_instance.pending_requests and from_uin not in _client_instance.contacts: - _client_instance.pending_requests.append(from_uin) - _client_instance.refresh_ui() - return - elif cmd == "RES_ACC": - if _client_instance.dialing_uin == from_uin: - _client_instance.dialing_uin = None - if from_uin not in _client_instance.contacts: - _client_instance.contacts[from_uin] = {"status": "online", "unread": 0, "attention": False} - _client_instance.history[from_uin] = ["[SYSTEM]: Handshake accepted. Contact added."] - _client_instance.save_config() - _client_instance.active_chat = from_uin - _client_instance.refresh_ui() - return - elif cmd == "RES_DEC" or cmd == "RES_BSY": - if _client_instance.dialing_uin == from_uin: - _client_instance.dialing_uin = None - return - - if from_uin not in _client_instance.contacts: - return - if _client_instance.active_chat != from_uin: - _client_instance.contacts[from_uin]["unread"] += 1 + if raw_bytes.startswith(b"SERVICE:"): + parts = raw_bytes.split(b":", 2) + proto_name = parts[1].decode('utf-8') + sig_bytes = parts[2] - threading.Thread(target=_client_instance.play_and_decode, args=(audio_bytes, from_uin), daemon=True).start() - except Exception as e: + sig_type = _client_instance.plugin_manager.safe_decode(proto_name, sig_bytes) + if not sig_type: + sig_type = _client_instance.plugin_manager.protocols.get(proto_name, {}).get("instance").detect_service_signal(sig_bytes) + + if sig_type: + if from_uin not in _client_instance.contacts: return + + if sig_type == "QUERY": + if _client_instance.is_busy: + _client_instance.send_service_tone(from_uin, 1600) + else: + _client_instance.pending_windows.append({"type": "query", "uin": from_uin}) + _client_instance.refresh_ui() + elif sig_type == "ALERT": + _client_instance.pending_windows.append({"type": "alert", "uin": from_uin}) + _client_instance.refresh_ui() + elif sig_type == "RESP_YES": + if _client_instance.dialing_uin == from_uin: + _client_instance.dialing_uin = None + _client_instance.add_to_history(from_uin, "[SYSTEM]: Peer reports they are currently BUSY.") + elif sig_type == "RESP_NO": + if _client_instance.dialing_uin == from_uin: + _client_instance.dialing_uin = None + _client_instance.add_to_history(from_uin, "[SYSTEM]: Peer reports they are AVAILABLE.") + return + + if raw_bytes.startswith(b"NATIVE:"): + parts = raw_bytes.split(b":", 2) + proto_name = parts[1].decode('utf-8') + enc_bytes = parts[2] + + if not _client_instance.plugin_manager.protocols.get(proto_name, {}).get("enabled"): + return + + decoded_text = _client_instance.plugin_manager.safe_decode(proto_name, enc_bytes) + if decoded_text is None: + return + + _client_instance.peer_session_protocols[from_uin] = proto_name + + if decoded_text.startswith("SYS:"): + cmd = decoded_text[4:] + if cmd == "REQ_ADD": + if from_uin not in _client_instance.contacts: + if from_uin not in _client_instance.pending_requests: + _client_instance.pending_requests.append(from_uin) + _client_instance.refresh_ui() + else: + _client_instance.send_sys_packet(from_uin, "RES_ACC") + return + elif cmd == "RES_ACC": + if _client_instance.dialing_uin == from_uin: + _client_instance.dialing_uin = None + if from_uin not in _client_instance.contacts: + _client_instance.contacts[from_uin] = {"status": "online", "unread": 0, "attention": False} + _client_instance.history[from_uin] = ["[SYSTEM]: System connection handshakes accepted and bound!"] + _client_instance.save_config() + _client_instance.active_chat = from_uin + _client_instance.refresh_ui() + return + elif cmd == "RES_DEC": + if _client_instance.dialing_uin == from_uin: + _client_instance.dialing_uin = None + return + elif cmd.startswith("REQ_JOIN_ROOM:"): + r_name = cmd.split(":", 1)[1] + r_id = f"ROOM:{_client_instance.uin}:{r_name}" + if r_id in _client_instance.groups: + if from_uin not in _client_instance.groups[r_id]["members"]: + _client_instance.groups[r_id]["members"].append(from_uin) + _client_instance.save_config() + _client_instance.send_sys_packet(from_uin, f"RES_JOIN_ROOM_OK:{r_name}:" + ",".join(_client_instance.groups[r_id]["members"])) + _client_instance.add_to_history(r_id, f"[SYSTEM]: User UIN {from_uin} joined the room sync list.") + return + elif cmd.startswith("RES_JOIN_ROOM_OK:"): + parts = cmd.split(":", 2) + r_name = parts[1] + m_list = parts[2].split(",") + r_id = f"ROOM:{from_uin}:{r_name}" + if r_id in _client_instance.groups: + _client_instance.groups[r_id]["members"] = m_list + _client_instance.save_config() + _client_instance.add_to_history(r_id, f"[SYSTEM]: Room synchronization complete. Members online: {len(m_list)}") + return + elif cmd.startswith("ROOM_MSG:"): + parts = cmd.split(":", 3) + r_id = parts[1] + sender_uin = parts[2] + msg_body = parts[3] + + if r_id in _client_instance.groups: + _client_instance.add_to_history(r_id, f"[UIN {sender_uin}]: {msg_body}") + if _client_instance.uin == _client_instance.groups[r_id]["owner"]: + for m in _client_instance.groups[r_id]["members"]: + if m != _client_instance.uin and m != sender_uin and _client_instance.contacts.get(m, {}).get("status") == "online": + sio.emit("relay_packet", {"to_uin": m, "payload": payload_base64}) + return + + if from_uin not in _client_instance.contacts: return + if _client_instance.active_chat != from_uin: + _client_instance.contacts[from_uin]["unread"] += 1 + + sender_name = f"UIN {from_uin}" + _client_instance.add_to_history(from_uin, f"[{sender_name}]: {decoded_text}") + except: pass @sio.event @@ -109,4 +149,5 @@ def error(data): _client_instance.dialing_uin = None if target in _client_instance.contacts: _client_instance.contacts[target]["status"] = "offline" - _client_instance.add_to_history(target, f"[SYSTEM]: They went offline. Outgoing stack will be preserved.") \ No newline at end of file + _client_instance.add_to_history(target, "[SYSTEM]: Contact went offline. Transmission preserved.") + _client_instance.refresh_ui() \ No newline at end of file diff --git a/transports/__init__.py b/transports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/transports/socketio_transport.py b/transports/socketio_transport.py new file mode 100644 index 0000000..b40682b --- /dev/null +++ b/transports/socketio_transport.py @@ -0,0 +1,31 @@ +import socketio + +class Plugin: + def __init__(self): + self.name = "Socket.IO Transport" + + def connect(self, server_url: str) -> bool: + from socket_manager import sio + try: + if not sio.connected: + sio.connect(server_url, transports=['websocket']) + return True + except: + return False + + def disconnect(self) -> None: + from socket_manager import sio + try: + sio.disconnect() + except: + pass + + def send_packet(self, to_uin: str, payload_b64: str) -> bool: + from socket_manager import sio + try: + if sio.connected: + sio.emit("relay_packet", {"to_uin": to_uin, "payload": payload_b64}) + return True + return False + except: + return False \ No newline at end of file