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 +20 -0
- trmchat-1.0.0/pyproject.toml +39 -0
- trmchat-1.0.0/setup.cfg +4 -0
- trmchat-1.0.0/termchat/__init__.py +1 -0
- trmchat-1.0.0/termchat/__main__.py +3 -0
- trmchat-1.0.0/termchat/api.py +195 -0
- trmchat-1.0.0/termchat/app.py +60 -0
- trmchat-1.0.0/termchat/auth.py +26 -0
- trmchat-1.0.0/termchat/config.py +12 -0
- trmchat-1.0.0/termchat/screens/__init__.py +0 -0
- trmchat-1.0.0/termchat/screens/chat.py +1185 -0
- trmchat-1.0.0/termchat/screens/group.py +135 -0
- trmchat-1.0.0/termchat/screens/login.py +108 -0
- trmchat-1.0.0/termchat/screens/new_chat.py +95 -0
- trmchat-1.0.0/termchat/screens/password.py +81 -0
- trmchat-1.0.0/termchat/screens/profile.py +129 -0
- trmchat-1.0.0/termchat/screens/search.py +81 -0
- trmchat-1.0.0/termchat/screens/status.py +61 -0
- trmchat-1.0.0/termchat/screens/theme.py +85 -0
- trmchat-1.0.0/termchat/termchat.tcss +527 -0
- trmchat-1.0.0/termchat/themes.py +244 -0
- trmchat-1.0.0/termchat/ws.py +123 -0
- trmchat-1.0.0/trmchat.egg-info/PKG-INFO +20 -0
- trmchat-1.0.0/trmchat.egg-info/SOURCES.txt +26 -0
- trmchat-1.0.0/trmchat.egg-info/dependency_links.txt +1 -0
- trmchat-1.0.0/trmchat.egg-info/entry_points.txt +2 -0
- trmchat-1.0.0/trmchat.egg-info/requires.txt +3 -0
- trmchat-1.0.0/trmchat.egg-info/top_level.txt +1 -0
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"
|
trmchat-1.0.0/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
@@ -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
|