trmchat 1.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.
trmchat-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: trmchat
3
+ Version: 1.0.0
4
+ Summary: Secure terminal messaging for developers — Bloomberg-style TUI chat
5
+ Author: difa
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/difa/term-chat
8
+ Keywords: chat,terminal,tui,messaging,textual
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Communications :: Chat
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: textual>=1.0.0
19
+ Requires-Dist: httpx>=0.27
20
+ Requires-Dist: websockets>=14.0
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "trmchat"
7
+ version = "1.0.0"
8
+ description = "Secure terminal messaging for developers — Bloomberg-style TUI chat"
9
+ requires-python = ">=3.10"
10
+ license = "MIT"
11
+ authors = [{name = "difa"}]
12
+ keywords = ["chat", "terminal", "tui", "messaging", "textual"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Environment :: Console",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: Communications :: Chat",
22
+ ]
23
+ dependencies = [
24
+ "textual>=1.0.0",
25
+ "httpx>=0.27",
26
+ "websockets>=14.0",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/difa/term-chat"
31
+
32
+ [tool.setuptools.packages.find]
33
+ include = ["termchat*"]
34
+
35
+ [tool.setuptools.package-data]
36
+ termchat = ["*.tcss"]
37
+
38
+ [project.scripts]
39
+ termchat = "termchat.app:main"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,3 @@
1
+ from termchat.app import main
2
+
3
+ main()
@@ -0,0 +1,195 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+
5
+ from termchat.auth import load_tokens, save_tokens
6
+ from termchat.config import API_URL
7
+
8
+
9
+ class ApiClient:
10
+ def __init__(self) -> None:
11
+ tokens = load_tokens()
12
+ self._access_token: str | None = tokens[0] if tokens else None
13
+ self._refresh_token: str | None = tokens[1] if tokens else None
14
+ self._client = httpx.AsyncClient(base_url=API_URL, timeout=10.0, follow_redirects=True)
15
+
16
+ def _headers(self) -> dict[str, str]:
17
+ if self._access_token:
18
+ return {"Authorization": f"Bearer {self._access_token}"}
19
+ return {}
20
+
21
+ async def _request(
22
+ self,
23
+ method: str,
24
+ path: str,
25
+ *,
26
+ json: dict | None = None,
27
+ params: dict | None = None,
28
+ auth_required: bool = True,
29
+ _retried: bool = False,
30
+ ) -> dict | list | None:
31
+ headers = self._headers() if auth_required else {}
32
+ resp = await self._client.request(
33
+ method, path, json=json, params=params, headers=headers
34
+ )
35
+ if resp.status_code == 401 and auth_required and not _retried:
36
+ refreshed = await self.refresh_token()
37
+ if refreshed:
38
+ return await self._request(
39
+ method, path, json=json, params=params,
40
+ auth_required=auth_required, _retried=True,
41
+ )
42
+ resp.raise_for_status()
43
+ if resp.status_code == 204:
44
+ return None
45
+ return resp.json()
46
+
47
+ # ── auth ──────────────────────────────────────────────────────
48
+
49
+ async def register(self, username: str, display_name: str, password: str) -> dict:
50
+ resp = await self._client.post(
51
+ "/auth/register",
52
+ json={"username": username, "display_name": display_name, "password": password},
53
+ )
54
+ resp.raise_for_status()
55
+ data = resp.json()
56
+ self._access_token = data["access_token"]
57
+ self._refresh_token = data["refresh_token"]
58
+ save_tokens(self._access_token, self._refresh_token)
59
+ return data
60
+
61
+ async def login(self, username: str, password: str) -> dict:
62
+ resp = await self._client.post(
63
+ "/auth/login",
64
+ json={"username": username, "password": password},
65
+ )
66
+ resp.raise_for_status()
67
+ data = resp.json()
68
+ self._access_token = data["access_token"]
69
+ self._refresh_token = data["refresh_token"]
70
+ save_tokens(self._access_token, self._refresh_token)
71
+ return data
72
+
73
+ async def refresh_token(self) -> bool:
74
+ if not self._refresh_token:
75
+ return False
76
+ try:
77
+ resp = await self._client.post(
78
+ "/auth/refresh",
79
+ json={"refresh_token": self._refresh_token},
80
+ )
81
+ if resp.status_code != 200:
82
+ return False
83
+ data = resp.json()
84
+ self._access_token = data["access_token"]
85
+ self._refresh_token = data["refresh_token"]
86
+ save_tokens(self._access_token, self._refresh_token)
87
+ return True
88
+ except Exception:
89
+ return False
90
+
91
+ # ── user ──────────────────────────────────────────────────────
92
+
93
+ async def get_me(self) -> dict:
94
+ return await self._request("GET", "/auth/me")
95
+
96
+ async def search_users(self, query: str) -> list:
97
+ return await self._request("GET", "/users/search", params={"q": query})
98
+
99
+ async def set_status(self, status: str) -> dict:
100
+ return await self._request("PUT", "/users/status", json={"status": status})
101
+
102
+ # ── conversations ─────────────────────────────────────────────
103
+
104
+ async def get_conversations(self) -> list:
105
+ return await self._request("GET", "/conversations/")
106
+
107
+ async def create_conversation(
108
+ self, conv_type: str, member_ids: list[str], name: str | None = None
109
+ ) -> dict:
110
+ payload: dict = {"type": conv_type, "member_ids": member_ids}
111
+ if name:
112
+ payload["name"] = name
113
+ return await self._request("POST", "/conversations/", json=payload)
114
+
115
+ async def get_profile(self) -> dict:
116
+ return await self._request("GET", "/users/profile")
117
+
118
+ async def update_profile(self, **fields) -> dict:
119
+ return await self._request("PUT", "/users/profile", json=fields)
120
+
121
+ async def get_user_profile(self, username: str) -> dict:
122
+ return await self._request("GET", f"/users/{username}/profile")
123
+
124
+ async def delete_conversation(self, conversation_id: str) -> None:
125
+ await self._request("DELETE", f"/conversations/{conversation_id}")
126
+
127
+ # ── messages ──────────────────────────────────────────────────
128
+
129
+ async def get_messages(
130
+ self, conversation_id: str, before: str | None = None, limit: int = 50
131
+ ) -> list:
132
+ params: dict = {"limit": limit}
133
+ if before:
134
+ params["before"] = before
135
+ return await self._request("GET", f"/messages/{conversation_id}", params=params)
136
+
137
+ async def send_message(
138
+ self, conversation_id: str, content: str, message_type: str = "text"
139
+ ) -> dict:
140
+ return await self._request(
141
+ "POST",
142
+ f"/messages/{conversation_id}",
143
+ json={"content": content, "message_type": message_type},
144
+ )
145
+
146
+ async def edit_message(
147
+ self, conversation_id: str, message_id: str, content: str
148
+ ) -> dict:
149
+ return await self._request(
150
+ "PUT",
151
+ f"/messages/{conversation_id}/{message_id}",
152
+ json={"content": content},
153
+ )
154
+
155
+ async def delete_message(self, conversation_id: str, message_id: str) -> None:
156
+ await self._request("DELETE", f"/messages/{conversation_id}/{message_id}")
157
+
158
+ # ── search ─────────────────────────────────────────────────
159
+
160
+ async def search_messages(self, conversation_id: str, query: str) -> list:
161
+ return await self._request(
162
+ "GET", f"/messages/search/{conversation_id}", params={"q": query}
163
+ )
164
+
165
+ # ── password ───────────────────────────────────────────────
166
+
167
+ async def change_password(self, current_password: str, new_password: str) -> dict:
168
+ return await self._request(
169
+ "PUT", "/auth/password",
170
+ json={"current_password": current_password, "new_password": new_password},
171
+ )
172
+
173
+ # ── group management ───────────────────────────────────────
174
+
175
+ async def rename_conversation(self, conversation_id: str, name: str) -> dict:
176
+ return await self._request(
177
+ "PUT", f"/conversations/{conversation_id}",
178
+ json={"name": name},
179
+ )
180
+
181
+ async def add_members(self, conversation_id: str, member_ids: list[str]) -> dict:
182
+ return await self._request(
183
+ "POST", f"/conversations/{conversation_id}/members",
184
+ json={"member_ids": member_ids},
185
+ )
186
+
187
+ async def remove_member(self, conversation_id: str, user_id: str) -> None:
188
+ await self._request(
189
+ "DELETE", f"/conversations/{conversation_id}/members/{user_id}",
190
+ )
191
+
192
+ # ── lifecycle ─────────────────────────────────────────────────
193
+
194
+ async def close(self) -> None:
195
+ await self._client.aclose()
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ from textual.app import App
4
+
5
+ from termchat.api import ApiClient
6
+ from termchat.config import CONFIG_DIR
7
+ from termchat.themes import DEFAULT_THEME, THEMES
8
+
9
+
10
+ class TermChatApp(App):
11
+ CSS_PATH = "termchat.tcss"
12
+ TITLE = "termchat"
13
+
14
+ def __init__(self) -> None:
15
+ super().__init__()
16
+ self.api = ApiClient()
17
+
18
+ def on_ready(self) -> None:
19
+ for t in THEMES:
20
+ self.register_theme(t)
21
+
22
+ theme_file = CONFIG_DIR / "theme"
23
+ if theme_file.exists():
24
+ saved = theme_file.read_text().strip()
25
+ theme_names = [t.name for t in THEMES]
26
+ if saved in theme_names:
27
+ self.theme = saved
28
+ return
29
+ self.theme = DEFAULT_THEME
30
+
31
+ async def on_mount(self) -> None:
32
+ from termchat.auth import load_tokens
33
+
34
+ tokens = load_tokens()
35
+ if tokens:
36
+ try:
37
+ await self.api.get_me()
38
+ from termchat.screens.chat import ChatScreen
39
+ self.push_screen(ChatScreen())
40
+ return
41
+ except Exception:
42
+ pass
43
+
44
+ from termchat.screens.login import LoginScreen
45
+ self.push_screen(LoginScreen())
46
+
47
+ async def on_unmount(self) -> None:
48
+ try:
49
+ await self.api.close()
50
+ except Exception:
51
+ pass
52
+
53
+
54
+ def main() -> None:
55
+ app = TermChatApp()
56
+ app.run()
57
+
58
+
59
+ if __name__ == "__main__":
60
+ main()
@@ -0,0 +1,26 @@
1
+ import json
2
+ from termchat.config import CONFIG_DIR, CREDENTIALS_FILE
3
+
4
+
5
+ def save_tokens(access_token: str, refresh_token: str) -> None:
6
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
7
+ CREDENTIALS_FILE.write_text(
8
+ json.dumps(
9
+ {"access_token": access_token, "refresh_token": refresh_token}
10
+ )
11
+ )
12
+
13
+
14
+ def load_tokens() -> tuple[str, str] | None:
15
+ if not CREDENTIALS_FILE.exists():
16
+ return None
17
+ try:
18
+ data = json.loads(CREDENTIALS_FILE.read_text())
19
+ return data["access_token"], data["refresh_token"]
20
+ except (json.JSONDecodeError, KeyError):
21
+ return None
22
+
23
+
24
+ def clear_tokens() -> None:
25
+ if CREDENTIALS_FILE.exists():
26
+ CREDENTIALS_FILE.unlink()
@@ -0,0 +1,12 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ SERVER_URL = os.environ.get(
5
+ "TERMCHAT_SERVER", "https://termchat-production-7e97.up.railway.app"
6
+ )
7
+ API_URL = f"{SERVER_URL}/api"
8
+ WS_URL = (
9
+ SERVER_URL.replace("https://", "wss://").replace("http://", "ws://") + "/api/ws"
10
+ )
11
+ CONFIG_DIR = Path.home() / ".termchat"
12
+ CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
File without changes