shellwhisper-cli 1.0.0__py3-none-any.whl

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.
app.py ADDED
@@ -0,0 +1,251 @@
1
+ from textual.app import App
2
+
3
+ from src.events import NewWhisperReceived
4
+ from src.screens.chat_screen import ChatScreen
5
+ from src.screens.login import LoginScreen
6
+ from src.utils.api_client import APIClient
7
+
8
+ import websocket
9
+ import json
10
+ import threading
11
+ import time
12
+ import os
13
+ import base64
14
+
15
+ class TerminalChatApp(App):
16
+ SCREENS = {
17
+ "login": LoginScreen,
18
+ "chat": ChatScreen,
19
+ }
20
+ BINDINGS = [
21
+ ("ctrl+d", "toggle_dark", "Toggle dark mode"),
22
+ ("ctrl+q", "quit", "Quit"),
23
+ ]
24
+ CSS_PATH = "styles/main.tcss"
25
+
26
+ def __init__(self):
27
+ super().__init__()
28
+ self.api = APIClient(self)
29
+ self.access_token = None
30
+ self.current_user = None
31
+ self.current_room_id = None
32
+ self.stomp_conn = None
33
+ self.file_cache = {}
34
+ self._ws_reconnect = True
35
+ self._ws_max_retries = 5
36
+
37
+ def on_mount(self) -> None:
38
+ self.push_screen(LoginScreen())
39
+
40
+ def action_logout(self) -> None:
41
+ self.logout_process()
42
+
43
+ def logout_process(self) -> None:
44
+ from src.screens.login import LoginScreen
45
+
46
+ try:
47
+ self.api.logout_backend()
48
+ except Exception:
49
+ pass
50
+
51
+ self._disconnect_websocket()
52
+ self.access_token = None
53
+ self.refresh_token = None
54
+ self.current_user = None
55
+ self.current_room_id = None
56
+ self.file_cache.clear()
57
+
58
+ self.notify("Logged out successfully", severity="information")
59
+ self.switch_screen(LoginScreen())
60
+
61
+
62
+ ### WEBSOCKET / STOMP ###
63
+
64
+ def connect_websocket(self):
65
+ self._ws_reconnect = True
66
+ thread = threading.Thread(target=self._ws_connect_loop, daemon=True)
67
+ thread.start()
68
+
69
+ def _ws_connect_loop(self):
70
+ delay = 1
71
+ attempts = 0
72
+
73
+ while self._ws_reconnect and attempts < self._ws_max_retries:
74
+ conn = None
75
+ try:
76
+ # ws_url = "ws://127.0.0.1:8080/chat/websocket"
77
+ ws_url = self.api.WS_URL
78
+ headers = [f"Authorization: Bearer {self.access_token}"]
79
+
80
+ conn = websocket.create_connection(ws_url, header=headers)
81
+ self.stomp_conn = conn
82
+
83
+ connect_frame = (
84
+ f"CONNECT\n"
85
+ f"accept-version:1.1,1.2\n"
86
+ f"heart-beat:0,0\n"
87
+ f"Authorization:Bearer {self.access_token}\n\n\x00"
88
+ )
89
+ conn.send(connect_frame)
90
+
91
+ response = conn.recv()
92
+ if "CONNECTED" not in response:
93
+ raise Exception(f"STOMP handshake failed: {response[:100]}")
94
+
95
+ personal_sync_frame = (
96
+ f"SUBSCRIBE\n"
97
+ f"id:sub-user-sync\n"
98
+ f"destination:/user/queue/rooms/refresh\n"
99
+ f"ack:auto\n"
100
+ f"Authorization:Bearer {self.access_token}\n\n\x00"
101
+ )
102
+ conn.send(personal_sync_frame)
103
+
104
+ if getattr(self, 'current_room_id', None):
105
+ restore_frame = (
106
+ f"SUBSCRIBE\n"
107
+ f"id:sub-{self.current_room_id}\n"
108
+ f"destination:/topic/room/{self.current_room_id}\n"
109
+ f"ack:auto\n"
110
+ f"Authorization:Bearer {self.access_token}\n\n\x00"
111
+ )
112
+ conn.send(restore_frame)
113
+
114
+ delay = 1
115
+ attempts = 0
116
+ self.call_from_thread(
117
+ self.notify, "Connected to ShellWhisper signal", severity="information"
118
+ )
119
+
120
+ loop_should_continue = self._listen_for_messages()
121
+ if loop_should_continue is False:
122
+ raise Exception("Broker explicitly rejected the subscription session.")
123
+
124
+ except Exception as e:
125
+ error_detail = f"{type(e).__name__}: {e}"
126
+ self.stomp_conn = None
127
+ if conn is not None:
128
+ try:
129
+ conn.close()
130
+ except Exception:
131
+ pass
132
+ conn = None
133
+ attempts += 1
134
+
135
+ if self._ws_reconnect and attempts < self._ws_max_retries:
136
+ self.call_from_thread(
137
+ self.notify,
138
+ f"WS error ({attempts}/{self._ws_max_retries}): {error_detail}",
139
+ severity="warning"
140
+ )
141
+ time.sleep(delay)
142
+ delay = min(delay * 2, 30)
143
+ else:
144
+ self.call_from_thread(
145
+ self.notify,
146
+ f"Could not connect: {error_detail}",
147
+ severity="error",
148
+ )
149
+
150
+ def _listen_for_messages(self):
151
+ while self.stomp_conn and self.stomp_conn.sock:
152
+ try:
153
+ raw_data = self.stomp_conn.recv()
154
+ if not raw_data:
155
+ continue
156
+
157
+ if isinstance(raw_data, bytes):
158
+ if "ExecutorSubscribableChannel" in raw_data:
159
+ self.call_from_thread(
160
+ self.notify,
161
+ "Authentication Interceptor Refused Room Subscription.",
162
+ severity="error"
163
+ )
164
+ return False
165
+
166
+ if raw_data.startswith("ERROR"):
167
+ self.call_from_thread(self.notify, f"Broker Error: {raw_data[:200]}", severity="error")
168
+
169
+ if "MESSAGE" in raw_data:
170
+ normalized_data = raw_data.replace('\r\n', '\n')
171
+ parts = normalized_data.split('\n\n', 1)
172
+
173
+ if len(parts) > 1:
174
+ body_str = parts[1].rstrip('\x00')
175
+
176
+ try:
177
+ if not body_str.strip():
178
+ continue
179
+
180
+ message_data = json.loads(body_str)
181
+
182
+ if message_data.get("content") == "ROOM_DELETED_SIGNAL":
183
+ sender_name = message_data.get("sender")
184
+ deleter = sender_name if sender_name != self.current_user else "You"
185
+
186
+ self.call_from_thread(
187
+ self.notify,
188
+ f"Active room has been deleted by {deleter}.",
189
+ severity="warning"
190
+ )
191
+
192
+ if self.current_room_id == message_data.get("roomId"):
193
+ self.current_room_id = None
194
+ self.file_cache.clear()
195
+ self.call_from_thread(self.screen._clear_to_empty_viewport)
196
+
197
+ self.call_from_thread(self.screen.refresh_rooms)
198
+ continue
199
+
200
+ self.call_from_thread(
201
+ self.screen.post_message, NewWhisperReceived(message_data)
202
+ )
203
+ except json.JSONDecodeError as json_err:
204
+ self.call_from_thread(self.notify, f"JSON Error: {json_err}", severity="error")
205
+ except Exception as inner_err:
206
+ self.call_from_thread(self.notify, f"Event Error: {inner_err}", severity="error")
207
+ else:
208
+ self.call_from_thread(self.notify, f"Malformed Frame Received: {raw_data[:100]}", severity="error")
209
+ except Exception as ws_err:
210
+ self.call_from_thread(self.notify, f"WebSocket crashed: {ws_err}", severity="error")
211
+ break
212
+
213
+ def _disconnect_websocket(self):
214
+ self._ws_reconnect = False
215
+ if self.stomp_conn:
216
+ try:
217
+ disconnect_frame = "DISCONNECT\n\n\x00"
218
+ self.stomp_conn.send(disconnect_frame)
219
+ self.stomp_conn.close()
220
+ except Exception:
221
+ pass
222
+ finally:
223
+ self.stomp_conn = None
224
+
225
+ ### File Download ###
226
+
227
+ def action_download_file(self, filename: str) -> None:
228
+ if filename not in self.file_cache:
229
+ self.notify(f"File '{filename}' not in cache.", severity="error")
230
+ return
231
+ try:
232
+ save_path = os.path.join(os.getcwd(), "downloads")
233
+ os.makedirs(save_path, exist_ok=True)
234
+
235
+ full_path = os.path.join(save_path, filename)
236
+ binary_data = base64.b64decode(self.file_cache[filename])
237
+
238
+ with open(full_path, "wb") as f:
239
+ f.write(binary_data)
240
+
241
+ self.notify(f"Whisper saved to: {full_path}", severity="success")
242
+ except Exception as e:
243
+ self.notify(f"Download failed: {e}", severity="error")
244
+
245
+
246
+ def main():
247
+ app = TerminalChatApp()
248
+ app.run()
249
+
250
+ if __name__ == "__main__":
251
+ main()
components/sidebar.py ADDED
@@ -0,0 +1,90 @@
1
+ from textual.widgets import Static, Label, Button, LoadingIndicator
2
+ from textual.containers import Vertical
3
+ from textual.app import ComposeResult
4
+
5
+ class Sidebar(Static):
6
+ def compose(self) -> ComposeResult:
7
+ yield Label("ShellWhisper", id="sidebar-title")
8
+ yield Static(classes='spacer')
9
+
10
+ yield Button("🌐 ROOM ACTIONS", id="btn_room_mgmt")
11
+ yield Button("✉️ PRIVATE WHISPER", id="btn_private")
12
+
13
+ yield Static(classes='spacer')
14
+ yield Label("MY ROOMS", id="section-label")
15
+
16
+ with Vertical(id="rooms-list"):
17
+ yield LoadingIndicator()
18
+
19
+ yield Static(classes='spacer')
20
+ yield Button("Logout", variant="error", id="logout_btn")
21
+
22
+ # async def update_rooms(self, rooms: list):
23
+ # rooms_list = self.query_one("#rooms-list")
24
+ # await rooms_list.query("*").remove()
25
+
26
+ # if not rooms:
27
+ # await rooms_list.mount(Label("No rooms yet...", classes="empty-msg"))
28
+ # else:
29
+ # current_user = self.app.current_user
30
+
31
+ # for room in rooms:
32
+ # display_name = room['roomName']
33
+
34
+ # if room.get("type") == "PRIVATE":
35
+ # clean_name = display_name.replace("private_", "")
36
+
37
+ # parts = clean_name.split("_")
38
+
39
+ # if len(parts) == 2:
40
+ # display_name = parts[1] if parts[0] == current_user else parts[0]
41
+
42
+ # btn_label = f"💬 {display_name}"
43
+ # else:
44
+ # btn_label = f"#{display_name}"
45
+
46
+ # btn = Button(
47
+ # # label=f"#{room['roomName']}",
48
+ # label=btn_label,
49
+ # id=f"room_{room['id']}",
50
+ # classes="room-link"
51
+ # )
52
+ # await rooms_list.mount(btn)
53
+
54
+ async def update_rooms(self, rooms: list):
55
+ rooms_list = self.query_one("#rooms-list")
56
+ await rooms_list.query("*").remove()
57
+
58
+ if not rooms:
59
+ await rooms_list.mount(Label("No rooms yet...", classes="empty-msg"))
60
+ else:
61
+ current_user = self.app.current_user
62
+
63
+ for room in rooms:
64
+ room_name = room['roomName']
65
+ room_type = room.get("type", "GROUP")
66
+
67
+ if room_type == "PRIVATE":
68
+ raw = room_name
69
+
70
+ if raw.startswith("private_"):
71
+ raw = raw[len("private_"):]
72
+
73
+ other = raw
74
+
75
+ if raw.startswith(current_user + "_"):
76
+ other = raw[len(current_user) + 1:]
77
+ elif raw.endswith("_" + current_user):
78
+ other = raw[: -(len(current_user) + 1)]
79
+
80
+ btn_label = f"💬 {other}"
81
+ else:
82
+ btn_label = room_name
83
+
84
+ btn = Button(
85
+ label=btn_label,
86
+ id=f"room_{room['id']}",
87
+ classes="room-link"
88
+ )
89
+
90
+ await rooms_list.mount(btn)
events.py ADDED
@@ -0,0 +1,6 @@
1
+ from textual.message import Message
2
+
3
+ class NewWhisperReceived(Message):
4
+ def __init__(self, data: dict) -> None:
5
+ self.data = data
6
+ super().__init__()