ChaTerminal 1.0.4__tar.gz → 2.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {chaterminal-1.0.4 → chaterminal-2.0.0}/ChaTerminal/__main__.py +1 -1
- chaterminal-2.0.0/ChaTerminal/core/__init__.py +0 -0
- chaterminal-2.0.0/ChaTerminal/core/events.py +14 -0
- chaterminal-2.0.0/ChaTerminal/core/logger.py +14 -0
- chaterminal-2.0.0/ChaTerminal/core/state.py +19 -0
- chaterminal-2.0.0/ChaTerminal/core/threads.py +30 -0
- chaterminal-2.0.0/ChaTerminal/crypto/__init__.py +0 -0
- chaterminal-2.0.0/ChaTerminal/main.py +79 -0
- chaterminal-2.0.0/ChaTerminal/services/__init__.py +0 -0
- chaterminal-2.0.0/ChaTerminal/services/auth_service.py +119 -0
- chaterminal-2.0.0/ChaTerminal/services/firebase_service.py +124 -0
- chaterminal-2.0.0/ChaTerminal/services/message_service.py +144 -0
- chaterminal-2.0.0/ChaTerminal/services/presence_service.py +82 -0
- chaterminal-2.0.0/ChaTerminal/services/websocket_service.py +77 -0
- chaterminal-2.0.0/ChaTerminal/storage/__init__.py +0 -0
- chaterminal-2.0.0/ChaTerminal/storage/database.py +82 -0
- chaterminal-2.0.0/ChaTerminal/storage/session_store.py +84 -0
- chaterminal-2.0.0/ChaTerminal/ui/__init__.py +0 -0
- chaterminal-2.0.0/ChaTerminal/ui/console.py +28 -0
- chaterminal-2.0.0/ChaTerminal/ui/panels.py +58 -0
- chaterminal-2.0.0/ChaTerminal/ui/splash.py +36 -0
- chaterminal-2.0.0/ChaTerminal/ui/terminal_ui.py +148 -0
- chaterminal-2.0.0/ChaTerminal.egg-info/PKG-INFO +158 -0
- chaterminal-2.0.0/ChaTerminal.egg-info/SOURCES.txt +33 -0
- chaterminal-2.0.0/ChaTerminal.egg-info/entry_points.txt +2 -0
- {chaterminal-1.0.4 → chaterminal-2.0.0}/ChaTerminal.egg-info/requires.txt +1 -0
- chaterminal-2.0.0/ChaTerminal.egg-info/top_level.txt +1 -0
- chaterminal-2.0.0/PKG-INFO +158 -0
- chaterminal-2.0.0/README.md +122 -0
- {chaterminal-1.0.4 → chaterminal-2.0.0}/setup.py +5 -4
- chaterminal-1.0.4/ChaTerminal/cli.py +0 -33
- chaterminal-1.0.4/ChaTerminal.egg-info/PKG-INFO +0 -121
- chaterminal-1.0.4/ChaTerminal.egg-info/SOURCES.txt +0 -16
- chaterminal-1.0.4/ChaTerminal.egg-info/entry_points.txt +0 -2
- chaterminal-1.0.4/ChaTerminal.egg-info/top_level.txt +0 -2
- chaterminal-1.0.4/PKG-INFO +0 -121
- chaterminal-1.0.4/README.md +0 -86
- chaterminal-1.0.4/cha_terminal/__init__.py +0 -1
- chaterminal-1.0.4/cha_terminal/client.py +0 -1276
- chaterminal-1.0.4/cha_terminal/splash.py +0 -40
- {chaterminal-1.0.4 → chaterminal-2.0.0}/ChaTerminal/__init__.py +0 -0
- /chaterminal-1.0.4/cha_terminal/crypto_utils.py → /chaterminal-2.0.0/ChaTerminal/crypto/encryption.py +0 -0
- {chaterminal-1.0.4 → chaterminal-2.0.0}/ChaTerminal.egg-info/dependency_links.txt +0 -0
- {chaterminal-1.0.4 → chaterminal-2.0.0}/LICENSE +0 -0
- {chaterminal-1.0.4 → chaterminal-2.0.0}/setup.cfg +0 -0
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
class EventType(Enum):
|
|
4
|
+
INCOMING_MESSAGE = "incoming_message"
|
|
5
|
+
SYSTEM_MESSAGE = "system_message"
|
|
6
|
+
LOGOUT = "logout"
|
|
7
|
+
STATE_CHANGE = "state_change"
|
|
8
|
+
ONLINE_USERS = "online_users"
|
|
9
|
+
MESSAGE_SENT = "message_sent"
|
|
10
|
+
MESSAGE_ERROR = "message_error"
|
|
11
|
+
|
|
12
|
+
def put_event(state, event_type: EventType, **kwargs):
|
|
13
|
+
kwargs["type"] = event_type
|
|
14
|
+
state.event_queue.put(kwargs)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from rich.logging import RichHandler
|
|
3
|
+
from chaterminal.ui.console import console
|
|
4
|
+
|
|
5
|
+
def setup_logger():
|
|
6
|
+
logging.basicConfig(
|
|
7
|
+
level=logging.INFO,
|
|
8
|
+
format="%(message)s",
|
|
9
|
+
datefmt="[%X]",
|
|
10
|
+
handlers=[RichHandler(console=console, rich_tracebacks=True, show_path=False)]
|
|
11
|
+
)
|
|
12
|
+
return logging.getLogger("chaterminal")
|
|
13
|
+
|
|
14
|
+
logger = setup_logger()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from queue import Queue
|
|
3
|
+
import threading
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class AppState:
|
|
7
|
+
running: bool = True
|
|
8
|
+
|
|
9
|
+
uid: str | None = None
|
|
10
|
+
token: str | None = None
|
|
11
|
+
refresh_token: str | None = None
|
|
12
|
+
|
|
13
|
+
username: str | None = None
|
|
14
|
+
device_id: str | None = None
|
|
15
|
+
|
|
16
|
+
token_expires_at: int = 0
|
|
17
|
+
|
|
18
|
+
event_queue: Queue = field(default_factory=Queue)
|
|
19
|
+
lock: threading.Lock = field(default_factory=threading.Lock)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import time
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger("chaterminal")
|
|
6
|
+
|
|
7
|
+
class WorkerManager:
|
|
8
|
+
def __init__(self, state):
|
|
9
|
+
self.state = state
|
|
10
|
+
self.threads = {}
|
|
11
|
+
|
|
12
|
+
def start(self, name, target, daemon=True):
|
|
13
|
+
def wrapper():
|
|
14
|
+
while self.state.running:
|
|
15
|
+
try:
|
|
16
|
+
target()
|
|
17
|
+
except Exception as e:
|
|
18
|
+
logger.error(f"[WORKER CRASH] {name}: {e}")
|
|
19
|
+
if not self.state.running:
|
|
20
|
+
break
|
|
21
|
+
time.sleep(3)
|
|
22
|
+
|
|
23
|
+
thread = threading.Thread(
|
|
24
|
+
target=wrapper,
|
|
25
|
+
daemon=daemon,
|
|
26
|
+
name=name
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
self.threads[name] = thread
|
|
30
|
+
thread.start()
|
|
File without changes
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
from chaterminal.core.state import AppState
|
|
4
|
+
from chaterminal.core.threads import WorkerManager
|
|
5
|
+
from chaterminal.core.logger import logger
|
|
6
|
+
from chaterminal.storage.database import Database
|
|
7
|
+
from chaterminal.storage.session_store import get_or_create_device_id
|
|
8
|
+
from chaterminal.services.firebase_service import FirebaseClient
|
|
9
|
+
from chaterminal.services.auth_service import AuthService
|
|
10
|
+
from chaterminal.services.websocket_service import WebsocketService
|
|
11
|
+
from chaterminal.services.presence_service import PresenceService
|
|
12
|
+
from chaterminal.services.message_service import MessageService
|
|
13
|
+
from chaterminal.ui.terminal_ui import TerminalUI
|
|
14
|
+
from chaterminal.crypto.encryption import get_public_key_pem
|
|
15
|
+
from chaterminal.ui.splash import splash
|
|
16
|
+
from chaterminal.ui.console import console
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main():
|
|
20
|
+
splash()
|
|
21
|
+
logger.info("[*] Initializing ChaTerminal v2...")
|
|
22
|
+
|
|
23
|
+
# 1. State
|
|
24
|
+
state = AppState()
|
|
25
|
+
state.device_id = get_or_create_device_id()
|
|
26
|
+
|
|
27
|
+
# 2. Storage
|
|
28
|
+
db = Database()
|
|
29
|
+
|
|
30
|
+
# 3. Services
|
|
31
|
+
firebase = FirebaseClient(state)
|
|
32
|
+
ws_service = WebsocketService(state)
|
|
33
|
+
presence = PresenceService(state, firebase, ws_service)
|
|
34
|
+
message_service = MessageService(state, firebase, ws_service, db)
|
|
35
|
+
|
|
36
|
+
# 4. Auth - show Live activation panel while waiting
|
|
37
|
+
live_login = None
|
|
38
|
+
|
|
39
|
+
def ui_activation_callback(code, remaining):
|
|
40
|
+
nonlocal live_login
|
|
41
|
+
from chaterminal.ui.panels import activation_panel
|
|
42
|
+
from rich.live import Live
|
|
43
|
+
if live_login is None:
|
|
44
|
+
live_login = Live(refresh_per_second=4, console=console)
|
|
45
|
+
live_login.start()
|
|
46
|
+
live_login.update(activation_panel(code, remaining))
|
|
47
|
+
|
|
48
|
+
auth = AuthService(state, firebase, ui_activation_callback)
|
|
49
|
+
success = auth.login_flow()
|
|
50
|
+
|
|
51
|
+
if live_login:
|
|
52
|
+
live_login.stop()
|
|
53
|
+
|
|
54
|
+
if not success:
|
|
55
|
+
logger.error("[!] Authentication failed. Exiting.")
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
|
|
58
|
+
logger.info(f"[+] Logged in as @{state.username}")
|
|
59
|
+
|
|
60
|
+
# 5. Register session & E2EE key
|
|
61
|
+
pub_key = get_public_key_pem()
|
|
62
|
+
presence.register_session(pub_key)
|
|
63
|
+
|
|
64
|
+
# 6. Start background workers
|
|
65
|
+
worker_manager = WorkerManager(state)
|
|
66
|
+
worker_manager.start("WebsocketClient", ws_service.run)
|
|
67
|
+
worker_manager.start("Heartbeat", presence.heartbeat_loop)
|
|
68
|
+
worker_manager.start("MessageSender", message_service.sender_worker)
|
|
69
|
+
|
|
70
|
+
# 7. Run UI - single-threaded, blocks until /exit or Ctrl+C
|
|
71
|
+
ui = TerminalUI(state, message_service, presence)
|
|
72
|
+
ui.run()
|
|
73
|
+
|
|
74
|
+
state.running = False
|
|
75
|
+
console.print("\n[cyan][*] Goodbye![/cyan]")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import secrets
|
|
3
|
+
from chaterminal.core.logger import logger
|
|
4
|
+
from chaterminal.core.events import EventType, put_event
|
|
5
|
+
from chaterminal.storage.session_store import save_session, load_session, delete_session
|
|
6
|
+
|
|
7
|
+
class AuthService:
|
|
8
|
+
def __init__(self, state, firebase_client, ui_callback=None):
|
|
9
|
+
self.state = state
|
|
10
|
+
self.firebase = firebase_client
|
|
11
|
+
self.ui_callback = ui_callback
|
|
12
|
+
|
|
13
|
+
def login_flow(self):
|
|
14
|
+
session = load_session()
|
|
15
|
+
|
|
16
|
+
if session:
|
|
17
|
+
self.state.token = session.get("idToken")
|
|
18
|
+
self.state.refresh_token = session.get("refreshToken")
|
|
19
|
+
self.state.uid = session.get("localId")
|
|
20
|
+
self.state.token_expires_at = session.get("expiresAt", 0)
|
|
21
|
+
|
|
22
|
+
if self.state.token_expires_at <= int(time.time()):
|
|
23
|
+
logger.info("[*] Refreshing session...")
|
|
24
|
+
if not self.firebase.refresh_token():
|
|
25
|
+
delete_session()
|
|
26
|
+
session = None
|
|
27
|
+
|
|
28
|
+
if not session:
|
|
29
|
+
code = self._generate_activation_code()
|
|
30
|
+
if not code:
|
|
31
|
+
logger.error("[!] Failed to generate activation code.")
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
res = self.firebase.put_public(
|
|
35
|
+
f"chaterminal/loginCodes/{code}",
|
|
36
|
+
{
|
|
37
|
+
"pending": True,
|
|
38
|
+
"createdAt": int(time.time() * 1000)
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
if res.status_code != 200:
|
|
42
|
+
logger.error(f"[!] Failed to write activation code: {res.text}")
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
data = self._poll_activation_code(code)
|
|
46
|
+
|
|
47
|
+
if not data:
|
|
48
|
+
logger.error("[!] Activation timed out.")
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
self.state.token = data["idToken"]
|
|
52
|
+
self.state.refresh_token = data["refreshToken"]
|
|
53
|
+
self.state.uid = data["localId"]
|
|
54
|
+
self.state.token_expires_at = int(time.time()) + int(data.get("expiresIn", 3600))
|
|
55
|
+
|
|
56
|
+
save_session({
|
|
57
|
+
"idToken": self.state.token,
|
|
58
|
+
"refreshToken": self.state.refresh_token,
|
|
59
|
+
"localId": self.state.uid,
|
|
60
|
+
"expiresAt": self.state.token_expires_at
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
# Fetch username
|
|
64
|
+
try:
|
|
65
|
+
res = self.firebase.get(f"users/{self.state.uid}/username")
|
|
66
|
+
if res.status_code == 200 and res.text.strip() != "null":
|
|
67
|
+
self.state.username = res.json()
|
|
68
|
+
else:
|
|
69
|
+
self.state.username = f"user_{self.state.uid[:6]}"
|
|
70
|
+
except Exception:
|
|
71
|
+
self.state.username = f"user_{self.state.uid[:6]}"
|
|
72
|
+
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
def _generate_activation_code(self):
|
|
76
|
+
alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
77
|
+
attempts = 0
|
|
78
|
+
while attempts < 10:
|
|
79
|
+
code = ''.join(secrets.choice(alphabet) for _ in range(8))
|
|
80
|
+
try:
|
|
81
|
+
res = self.firebase.get_public(f"chaterminal/loginCodes/{code}")
|
|
82
|
+
if res.status_code == 200 and res.text.strip() == "null":
|
|
83
|
+
return code
|
|
84
|
+
elif res.status_code != 200:
|
|
85
|
+
logger.error(f"Error checking code: {res.status_code} {res.text}")
|
|
86
|
+
except Exception as e:
|
|
87
|
+
logger.error(f"Exception checking code: {e}")
|
|
88
|
+
attempts += 1
|
|
89
|
+
time.sleep(1)
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
def _poll_activation_code(self, code, timeout=300):
|
|
93
|
+
path = f"chaterminal/loginCodes/{code}"
|
|
94
|
+
deadline = time.time() + timeout
|
|
95
|
+
|
|
96
|
+
while time.time() < deadline:
|
|
97
|
+
remaining = int(deadline - time.time())
|
|
98
|
+
|
|
99
|
+
if self.ui_callback:
|
|
100
|
+
self.ui_callback(code, remaining)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
res = self.firebase.get_public(path)
|
|
104
|
+
if res.status_code == 200 and res.text.strip() != "null":
|
|
105
|
+
data = res.json()
|
|
106
|
+
if isinstance(data, dict) and data.get("idToken"):
|
|
107
|
+
delete_res = self.firebase.delete_public(
|
|
108
|
+
path,
|
|
109
|
+
params={"auth": data["idToken"]}
|
|
110
|
+
)
|
|
111
|
+
if delete_res.status_code != 200:
|
|
112
|
+
logger.error(f"[!] Failed to delete activation code: {delete_res.status_code} {delete_res.text}")
|
|
113
|
+
return data
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
time.sleep(1)
|
|
118
|
+
|
|
119
|
+
return None
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
import time
|
|
3
|
+
from chaterminal.core.logger import logger
|
|
4
|
+
from chaterminal.core.events import EventType, put_event
|
|
5
|
+
|
|
6
|
+
BASE_URL = "https://memerdevs-default-rtdb.firebaseio.com"
|
|
7
|
+
API_KEY = "AIzaSyChVoNLaFnfZScOmOvzf25xTPnvuKOA9a0"
|
|
8
|
+
SESSION_TIMEOUT = (10, 30)
|
|
9
|
+
|
|
10
|
+
class FirebaseClient:
|
|
11
|
+
def __init__(self, state):
|
|
12
|
+
self.state = state
|
|
13
|
+
self.http = requests.Session()
|
|
14
|
+
self.http.headers.update({
|
|
15
|
+
"User-Agent": "ChaTerminal/2.0"
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
def get_public(self, path, **kwargs):
|
|
19
|
+
return self.http.get(
|
|
20
|
+
f"{BASE_URL}/{path}.json",
|
|
21
|
+
timeout=SESSION_TIMEOUT,
|
|
22
|
+
**kwargs
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def put_public(self, path, data, **kwargs):
|
|
26
|
+
return self.http.put(
|
|
27
|
+
f"{BASE_URL}/{path}.json",
|
|
28
|
+
json=data,
|
|
29
|
+
timeout=SESSION_TIMEOUT,
|
|
30
|
+
**kwargs
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def delete_public(self, path, **kwargs):
|
|
34
|
+
return self.http.delete(
|
|
35
|
+
f"{BASE_URL}/{path}.json",
|
|
36
|
+
timeout=SESSION_TIMEOUT,
|
|
37
|
+
**kwargs
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def get(self, path, **kwargs):
|
|
41
|
+
return self.request("GET", path, **kwargs)
|
|
42
|
+
|
|
43
|
+
def post(self, path, data, **kwargs):
|
|
44
|
+
return self.request("POST", path, json=data, **kwargs)
|
|
45
|
+
|
|
46
|
+
def put(self, path, data, **kwargs):
|
|
47
|
+
return self.request("PUT", path, json=data, **kwargs)
|
|
48
|
+
|
|
49
|
+
def patch(self, path, data, **kwargs):
|
|
50
|
+
return self.request("PATCH", path, json=data, **kwargs)
|
|
51
|
+
|
|
52
|
+
def delete(self, path, **kwargs):
|
|
53
|
+
return self.request("DELETE", path, **kwargs)
|
|
54
|
+
|
|
55
|
+
def request(self, method, path, **kwargs):
|
|
56
|
+
self.ensure_token()
|
|
57
|
+
|
|
58
|
+
headers = kwargs.pop("headers", {})
|
|
59
|
+
params = kwargs.pop("params", {})
|
|
60
|
+
if not self.state.token:
|
|
61
|
+
logger.error("Authenticated request attempted without token")
|
|
62
|
+
raise RuntimeError("Missing token")
|
|
63
|
+
|
|
64
|
+
headers["Authorization"] = f"Bearer {self.state.token}"
|
|
65
|
+
params["auth"] = self.state.token
|
|
66
|
+
url = f"{BASE_URL}/{path}.json"
|
|
67
|
+
|
|
68
|
+
return self.http.request(
|
|
69
|
+
method,
|
|
70
|
+
url,
|
|
71
|
+
headers=headers,
|
|
72
|
+
params=params,
|
|
73
|
+
timeout=SESSION_TIMEOUT,
|
|
74
|
+
**kwargs
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def ensure_token(self):
|
|
78
|
+
if not self.state.refresh_token:
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
remaining = self.state.token_expires_at - int(time.time())
|
|
82
|
+
if remaining > 300:
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
self.refresh_token()
|
|
86
|
+
|
|
87
|
+
def refresh_token(self):
|
|
88
|
+
url = f"https://securetoken.googleapis.com/v1/token?key={API_KEY}"
|
|
89
|
+
try:
|
|
90
|
+
res = self.http.post(
|
|
91
|
+
url,
|
|
92
|
+
data={
|
|
93
|
+
"grant_type": "refresh_token",
|
|
94
|
+
"refresh_token": self.state.refresh_token
|
|
95
|
+
},
|
|
96
|
+
timeout=SESSION_TIMEOUT
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
data = res.json() if res.status_code == 200 else None
|
|
100
|
+
|
|
101
|
+
if not data:
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
self.state.token = data["access_token"]
|
|
105
|
+
self.state.refresh_token = data["refresh_token"]
|
|
106
|
+
self.state.uid = data["user_id"]
|
|
107
|
+
self.state.token_expires_at = int(time.time()) + int(data["expires_in"])
|
|
108
|
+
|
|
109
|
+
put_event(self.state, EventType.STATE_CHANGE, field="token")
|
|
110
|
+
|
|
111
|
+
# Update session store
|
|
112
|
+
from chaterminal.storage.session_store import save_session
|
|
113
|
+
save_session({
|
|
114
|
+
"idToken": self.state.token,
|
|
115
|
+
"refreshToken": self.state.refresh_token,
|
|
116
|
+
"localId": self.state.uid,
|
|
117
|
+
"expiresAt": self.state.token_expires_at
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
except Exception as e:
|
|
123
|
+
logger.error(f"[!] Token refresh failed: {e}")
|
|
124
|
+
return False
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from queue import Queue, Empty
|
|
3
|
+
from chaterminal.core.logger import logger
|
|
4
|
+
from chaterminal.core.events import EventType, put_event
|
|
5
|
+
from chaterminal.crypto.encryption import encrypt_msg, decrypt_msg
|
|
6
|
+
from chaterminal.storage.database import Database
|
|
7
|
+
|
|
8
|
+
class MessageService:
|
|
9
|
+
def __init__(self, state, firebase_client, ws_service, db):
|
|
10
|
+
self.state = state
|
|
11
|
+
self.firebase = firebase_client
|
|
12
|
+
self.ws_service = ws_service
|
|
13
|
+
self.db = db
|
|
14
|
+
self.outgoing_queue = Queue()
|
|
15
|
+
self.username_cache = {}
|
|
16
|
+
self.CACHE_TTL = 300
|
|
17
|
+
|
|
18
|
+
def get_username_by_uid(self, uid):
|
|
19
|
+
now = time.time()
|
|
20
|
+
cached = self.username_cache.get(uid)
|
|
21
|
+
|
|
22
|
+
if cached:
|
|
23
|
+
username, ts = cached
|
|
24
|
+
if now - ts < self.CACHE_TTL:
|
|
25
|
+
return username
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
res = self.firebase.get(f"users/{uid}/username")
|
|
29
|
+
username = res.json() if res.status_code == 200 else None
|
|
30
|
+
|
|
31
|
+
if username and username != "null":
|
|
32
|
+
self.username_cache[uid] = (username, now)
|
|
33
|
+
return username
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
return f"user_{uid[:6]}"
|
|
38
|
+
|
|
39
|
+
def process_incoming_message(self, msg_id, payload):
|
|
40
|
+
sender_uid = payload.get("senderUid")
|
|
41
|
+
encrypted_payload = payload.get("encryptedPayload")
|
|
42
|
+
timestamp = payload.get("timestamp", int(time.time() * 1000))
|
|
43
|
+
|
|
44
|
+
if not sender_uid or not encrypted_payload:
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
text = decrypt_msg(encrypted_payload)
|
|
49
|
+
sender_username = self.get_username_by_uid(sender_uid)
|
|
50
|
+
|
|
51
|
+
if sender_uid == self.state.uid:
|
|
52
|
+
if msg_id != "ws":
|
|
53
|
+
self.firebase.delete(f"chaterminal/mailbox/{self.state.uid}/{msg_id}")
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
# Save to local DB
|
|
57
|
+
self.db.save_message(
|
|
58
|
+
sender_uid,
|
|
59
|
+
sender_username,
|
|
60
|
+
self.state.uid,
|
|
61
|
+
text,
|
|
62
|
+
timestamp
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Delete from mailbox if it came from SSE (if using REST fallback)
|
|
66
|
+
# For WebSockets, the gateway might handle ACKs, but we can do it here too if needed.
|
|
67
|
+
self.firebase.delete(f"chaterminal/mailbox/{self.state.uid}/{msg_id}")
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
"sender_username": sender_username,
|
|
71
|
+
"text": text,
|
|
72
|
+
"timestamp": timestamp
|
|
73
|
+
}
|
|
74
|
+
except Exception as e:
|
|
75
|
+
logger.error(f"[!] Decryption failed: {e}")
|
|
76
|
+
self.firebase.delete(f"chaterminal/mailbox/{self.state.uid}/{msg_id}")
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
def queue_message(self, slug, message):
|
|
80
|
+
self.outgoing_queue.put({
|
|
81
|
+
"slug": slug,
|
|
82
|
+
"message": message
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
def sender_worker(self):
|
|
86
|
+
while self.state.running:
|
|
87
|
+
try:
|
|
88
|
+
task = self.outgoing_queue.get(timeout=1)
|
|
89
|
+
slug = task["slug"]
|
|
90
|
+
message = task["message"]
|
|
91
|
+
|
|
92
|
+
res = self.firebase.get(f"slugIndex/{slug}")
|
|
93
|
+
recipient_uid = res.json() if res.status_code == 200 else None
|
|
94
|
+
|
|
95
|
+
if not recipient_uid or recipient_uid == "null":
|
|
96
|
+
put_event(self.state, EventType.SYSTEM_MESSAGE, text=f"[red][!] User {slug} not found.[/red]")
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
res = self.firebase.get(f"users/{recipient_uid}/chaterminal/publicKey")
|
|
100
|
+
pub_key = res.json() if res.status_code == 200 else None
|
|
101
|
+
|
|
102
|
+
if not pub_key or pub_key == "null":
|
|
103
|
+
put_event(self.state, EventType.SYSTEM_MESSAGE, text=f"[red][!] User {slug} has no active terminal session.[/red]")
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
encrypted_payload = encrypt_msg(message, pub_key)
|
|
107
|
+
|
|
108
|
+
payload = {
|
|
109
|
+
"senderUid": self.state.uid,
|
|
110
|
+
"encryptedPayload": encrypted_payload,
|
|
111
|
+
"timestamp": int(time.time() * 1000)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# Try sending via WS first, fallback to REST
|
|
115
|
+
success = False
|
|
116
|
+
if self.ws_service and self.ws_service.ws and self.ws_service.ws.sock and self.ws_service.ws.sock.connected:
|
|
117
|
+
ws_payload = {
|
|
118
|
+
"action": "send_message",
|
|
119
|
+
"recipientUid": recipient_uid,
|
|
120
|
+
"data": payload
|
|
121
|
+
}
|
|
122
|
+
success = self.ws_service.send(ws_payload)
|
|
123
|
+
|
|
124
|
+
if not success:
|
|
125
|
+
res = self.firebase.post(f"chaterminal/mailbox/{recipient_uid}", payload)
|
|
126
|
+
success = res.status_code == 200
|
|
127
|
+
|
|
128
|
+
if success:
|
|
129
|
+
# Save to self DB
|
|
130
|
+
self.db.save_message(
|
|
131
|
+
self.state.uid,
|
|
132
|
+
self.state.username,
|
|
133
|
+
recipient_uid,
|
|
134
|
+
message,
|
|
135
|
+
payload["timestamp"]
|
|
136
|
+
)
|
|
137
|
+
put_event(self.state, EventType.MESSAGE_SENT, slug=slug, message=message, timestamp=payload["timestamp"])
|
|
138
|
+
else:
|
|
139
|
+
put_event(self.state, EventType.SYSTEM_MESSAGE, text=f"[red][!] Failed to send to {slug}.[/red]")
|
|
140
|
+
|
|
141
|
+
except Empty:
|
|
142
|
+
continue
|
|
143
|
+
except Exception as e:
|
|
144
|
+
logger.error(f"[!] Send error: {e}")
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from chaterminal.core.logger import logger
|
|
3
|
+
from chaterminal.core.events import EventType, put_event
|
|
4
|
+
from chaterminal.storage.session_store import get_device_name
|
|
5
|
+
|
|
6
|
+
class PresenceService:
|
|
7
|
+
def __init__(self, state, firebase_client, ws_service):
|
|
8
|
+
self.state = state
|
|
9
|
+
self.firebase = firebase_client
|
|
10
|
+
self.ws_service = ws_service
|
|
11
|
+
|
|
12
|
+
def register_session(self, pub_key):
|
|
13
|
+
try:
|
|
14
|
+
device_name = get_device_name()
|
|
15
|
+
session_res = self.firebase.put(
|
|
16
|
+
f"chaterminal/sessions/{self.state.uid}",
|
|
17
|
+
{
|
|
18
|
+
"activeDeviceId": self.state.device_id,
|
|
19
|
+
"deviceName": device_name,
|
|
20
|
+
"loginTime": int(time.time() * 1000)
|
|
21
|
+
}
|
|
22
|
+
)
|
|
23
|
+
if session_res.status_code != 200:
|
|
24
|
+
logger.error(f"[!] Failed to write terminal session: {session_res.status_code} {session_res.text}")
|
|
25
|
+
|
|
26
|
+
user_res = self.firebase.patch(
|
|
27
|
+
f"users/{self.state.uid}/chaterminal",
|
|
28
|
+
{
|
|
29
|
+
"publicKey": pub_key,
|
|
30
|
+
"deviceId": self.state.device_id,
|
|
31
|
+
"deviceName": device_name,
|
|
32
|
+
"lastActive": int(time.time() * 1000)
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
if user_res.status_code != 200:
|
|
36
|
+
logger.error(f"[!] Failed to write terminal profile: {user_res.status_code} {user_res.text}")
|
|
37
|
+
except Exception as e:
|
|
38
|
+
logger.error(f"[!] Failed to register session: {e}")
|
|
39
|
+
|
|
40
|
+
def heartbeat_loop(self):
|
|
41
|
+
while self.state.running:
|
|
42
|
+
try:
|
|
43
|
+
# If we have a connected websocket, we might not need HTTP heartbeat,
|
|
44
|
+
# but we'll do both or rely on the backend to update presence via WS.
|
|
45
|
+
# For now, keep the Firebase patch.
|
|
46
|
+
self.firebase.patch(
|
|
47
|
+
f"users/{self.state.uid}/chaterminal",
|
|
48
|
+
{
|
|
49
|
+
"lastActive": int(time.time() * 1000)
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
time.sleep(60)
|
|
56
|
+
|
|
57
|
+
def fetch_online_users(self):
|
|
58
|
+
try:
|
|
59
|
+
res = self.firebase.get("users")
|
|
60
|
+
users_data = res.json() if res.status_code == 200 else {}
|
|
61
|
+
|
|
62
|
+
if not isinstance(users_data, dict):
|
|
63
|
+
return []
|
|
64
|
+
|
|
65
|
+
now = int(time.time() * 1000)
|
|
66
|
+
online = []
|
|
67
|
+
|
|
68
|
+
for _, user_info in users_data.items():
|
|
69
|
+
if not isinstance(user_info, dict):
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
username = user_info.get("username", "Unknown")
|
|
73
|
+
ct = user_info.get("chaterminal", {})
|
|
74
|
+
last_active = ct.get("lastActive", 0)
|
|
75
|
+
|
|
76
|
+
if last_active and (now - last_active < 300000):
|
|
77
|
+
online.append(username)
|
|
78
|
+
|
|
79
|
+
return online
|
|
80
|
+
except Exception as e:
|
|
81
|
+
logger.error(f"[!] Failed to fetch online users: {e}")
|
|
82
|
+
return []
|