Незаконченное обновление с добавлением модулей
This commit is contained in:
parent
ec504574f2
commit
6dab73fae9
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1 +1,2 @@
|
|||
settings.json
|
||||
venv
|
||||
428
TUI.py
428
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():
|
||||
|
|
|
|||
40
compile.py
40
compile.py
|
|
@ -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()
|
||||
124
extra/compile.py
Normal file
124
extra/compile.py
Normal file
|
|
@ -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()
|
||||
103
protocol.py
103
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 ""
|
||||
|
||||
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())
|
||||
24
protocols/__init__.py
Normal file
24
protocols/__init__.py
Normal file
|
|
@ -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
|
||||
73
protocols/audio_protocol.py
Normal file
73
protocols/audio_protocol.py
Normal file
|
|
@ -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
|
||||
21
protocols/text_protocol.py
Normal file
21
protocols/text_protocol.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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.")
|
||||
_client_instance.add_to_history(target, "[SYSTEM]: Contact went offline. Transmission preserved.")
|
||||
_client_instance.refresh_ui()
|
||||
0
transports/__init__.py
Normal file
0
transports/__init__.py
Normal file
31
transports/socketio_transport.py
Normal file
31
transports/socketio_transport.py
Normal file
|
|
@ -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
|
||||
Reference in a new issue