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.
Files changed (45) hide show
  1. {chaterminal-1.0.4 → chaterminal-2.0.0}/ChaTerminal/__main__.py +1 -1
  2. chaterminal-2.0.0/ChaTerminal/core/__init__.py +0 -0
  3. chaterminal-2.0.0/ChaTerminal/core/events.py +14 -0
  4. chaterminal-2.0.0/ChaTerminal/core/logger.py +14 -0
  5. chaterminal-2.0.0/ChaTerminal/core/state.py +19 -0
  6. chaterminal-2.0.0/ChaTerminal/core/threads.py +30 -0
  7. chaterminal-2.0.0/ChaTerminal/crypto/__init__.py +0 -0
  8. chaterminal-2.0.0/ChaTerminal/main.py +79 -0
  9. chaterminal-2.0.0/ChaTerminal/services/__init__.py +0 -0
  10. chaterminal-2.0.0/ChaTerminal/services/auth_service.py +119 -0
  11. chaterminal-2.0.0/ChaTerminal/services/firebase_service.py +124 -0
  12. chaterminal-2.0.0/ChaTerminal/services/message_service.py +144 -0
  13. chaterminal-2.0.0/ChaTerminal/services/presence_service.py +82 -0
  14. chaterminal-2.0.0/ChaTerminal/services/websocket_service.py +77 -0
  15. chaterminal-2.0.0/ChaTerminal/storage/__init__.py +0 -0
  16. chaterminal-2.0.0/ChaTerminal/storage/database.py +82 -0
  17. chaterminal-2.0.0/ChaTerminal/storage/session_store.py +84 -0
  18. chaterminal-2.0.0/ChaTerminal/ui/__init__.py +0 -0
  19. chaterminal-2.0.0/ChaTerminal/ui/console.py +28 -0
  20. chaterminal-2.0.0/ChaTerminal/ui/panels.py +58 -0
  21. chaterminal-2.0.0/ChaTerminal/ui/splash.py +36 -0
  22. chaterminal-2.0.0/ChaTerminal/ui/terminal_ui.py +148 -0
  23. chaterminal-2.0.0/ChaTerminal.egg-info/PKG-INFO +158 -0
  24. chaterminal-2.0.0/ChaTerminal.egg-info/SOURCES.txt +33 -0
  25. chaterminal-2.0.0/ChaTerminal.egg-info/entry_points.txt +2 -0
  26. {chaterminal-1.0.4 → chaterminal-2.0.0}/ChaTerminal.egg-info/requires.txt +1 -0
  27. chaterminal-2.0.0/ChaTerminal.egg-info/top_level.txt +1 -0
  28. chaterminal-2.0.0/PKG-INFO +158 -0
  29. chaterminal-2.0.0/README.md +122 -0
  30. {chaterminal-1.0.4 → chaterminal-2.0.0}/setup.py +5 -4
  31. chaterminal-1.0.4/ChaTerminal/cli.py +0 -33
  32. chaterminal-1.0.4/ChaTerminal.egg-info/PKG-INFO +0 -121
  33. chaterminal-1.0.4/ChaTerminal.egg-info/SOURCES.txt +0 -16
  34. chaterminal-1.0.4/ChaTerminal.egg-info/entry_points.txt +0 -2
  35. chaterminal-1.0.4/ChaTerminal.egg-info/top_level.txt +0 -2
  36. chaterminal-1.0.4/PKG-INFO +0 -121
  37. chaterminal-1.0.4/README.md +0 -86
  38. chaterminal-1.0.4/cha_terminal/__init__.py +0 -1
  39. chaterminal-1.0.4/cha_terminal/client.py +0 -1276
  40. chaterminal-1.0.4/cha_terminal/splash.py +0 -40
  41. {chaterminal-1.0.4 → chaterminal-2.0.0}/ChaTerminal/__init__.py +0 -0
  42. /chaterminal-1.0.4/cha_terminal/crypto_utils.py → /chaterminal-2.0.0/ChaTerminal/crypto/encryption.py +0 -0
  43. {chaterminal-1.0.4 → chaterminal-2.0.0}/ChaTerminal.egg-info/dependency_links.txt +0 -0
  44. {chaterminal-1.0.4 → chaterminal-2.0.0}/LICENSE +0 -0
  45. {chaterminal-1.0.4 → chaterminal-2.0.0}/setup.cfg +0 -0
@@ -1,4 +1,4 @@
1
- from ChaTerminal.cli import main
1
+ from chaterminal.main import main
2
2
 
3
3
  if __name__ == "__main__":
4
4
  main()
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 []