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 +251 -0
- components/sidebar.py +90 -0
- events.py +6 -0
- screens/chat_screen.py +661 -0
- screens/forgot_password.py +79 -0
- screens/join_screen.py +23 -0
- screens/login.py +104 -0
- screens/private_whisper_screen.py +26 -0
- screens/room_action_screen.py +35 -0
- screens/security_screen.py +45 -0
- shellwhisper_cli-1.0.0.dist-info/METADATA +14 -0
- shellwhisper_cli-1.0.0.dist-info/RECORD +16 -0
- shellwhisper_cli-1.0.0.dist-info/WHEEL +5 -0
- shellwhisper_cli-1.0.0.dist-info/entry_points.txt +2 -0
- shellwhisper_cli-1.0.0.dist-info/top_level.txt +5 -0
- utils/api_client.py +133 -0
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)
|