circle-chat 0.1.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.
- circle_chat-0.1.0/.gitignore +8 -0
- circle_chat-0.1.0/PKG-INFO +20 -0
- circle_chat-0.1.0/README.md +6 -0
- circle_chat-0.1.0/pyproject.toml +42 -0
- circle_chat-0.1.0/src/circle/__init__.py +1 -0
- circle_chat-0.1.0/src/circle/api.py +241 -0
- circle_chat-0.1.0/src/circle/app.py +739 -0
- circle_chat-0.1.0/src/circle/bootstrap.py +72 -0
- circle_chat-0.1.0/src/circle/call.py +315 -0
- circle_chat-0.1.0/src/circle/circle.tcss +287 -0
- circle_chat-0.1.0/src/circle/cli.py +25 -0
- circle_chat-0.1.0/src/circle/config.py +32 -0
- circle_chat-0.1.0/src/circle/session.py +57 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: circle-chat
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Chat with your circle of friends, entirely in the terminal.
|
|
5
|
+
Project-URL: Repository, https://github.com/bgaibull/circle
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Requires-Dist: livekit>=1.1.17
|
|
9
|
+
Requires-Dist: opencv-python-headless>=5.0.0.93
|
|
10
|
+
Requires-Dist: supabase
|
|
11
|
+
Requires-Dist: textual
|
|
12
|
+
Requires-Dist: textual-image>=0.13.2
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# circle
|
|
16
|
+
|
|
17
|
+
Chat with your circle of friends, entirely in the terminal.
|
|
18
|
+
|
|
19
|
+
uv tool install circle-chat
|
|
20
|
+
circle
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "circle-chat"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Chat with your circle of friends, entirely in the terminal."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"textual",
|
|
10
|
+
"supabase",
|
|
11
|
+
"livekit>=1.1.17",
|
|
12
|
+
"textual-image>=0.13.2",
|
|
13
|
+
"opencv-python-headless>=5.0.0.93",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
circle = "circle.cli:main"
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = [
|
|
21
|
+
"livekit-api>=1.2.1",
|
|
22
|
+
"pytest",
|
|
23
|
+
"pytest-asyncio",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["hatchling"]
|
|
28
|
+
build-backend = "hatchling.build"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/circle"]
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.sdist]
|
|
34
|
+
include = ["src/circle", "README.md", "pyproject.toml"]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Repository = "https://github.com/bgaibull/circle"
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
asyncio_mode = "auto"
|
|
41
|
+
testpaths = ["tests"]
|
|
42
|
+
filterwarnings = ["ignore::DeprecationWarning:supabase"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""circle — terminal chat for a closed circle of friends."""
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Everything the TUI needs from Supabase, in one thin async wrapper. No UI here."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Awaitable, Callable, Optional
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from realtime import RealtimeSubscribeStates
|
|
11
|
+
from supabase import AsyncClient, AsyncClientOptions, acreate_client
|
|
12
|
+
from supabase_auth.errors import AuthApiError
|
|
13
|
+
|
|
14
|
+
from .config import SUPABASE_KEY, SUPABASE_URL
|
|
15
|
+
from .session import FileStorage
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Friend:
|
|
20
|
+
id: str
|
|
21
|
+
name: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Message:
|
|
26
|
+
id: int
|
|
27
|
+
sender_id: str
|
|
28
|
+
recipient_id: str
|
|
29
|
+
body: str
|
|
30
|
+
created_at: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Circle:
|
|
34
|
+
def __init__(self, storage: Optional[FileStorage] = None) -> None:
|
|
35
|
+
self.storage = storage or FileStorage()
|
|
36
|
+
self.client: AsyncClient | None = None
|
|
37
|
+
self.user_id: str | None = None
|
|
38
|
+
self._channel = None
|
|
39
|
+
self._signal_channel = None
|
|
40
|
+
self.online: set[str] = set()
|
|
41
|
+
|
|
42
|
+
# ---- lifecycle -------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
async def connect(self) -> bool:
|
|
45
|
+
"""Create the client; return True when a stored session is still usable."""
|
|
46
|
+
self.client = await acreate_client(
|
|
47
|
+
SUPABASE_URL, SUPABASE_KEY,
|
|
48
|
+
AsyncClientOptions(storage=self.storage, auto_refresh_token=True, persist_session=True),
|
|
49
|
+
)
|
|
50
|
+
try:
|
|
51
|
+
session = await self.client.auth.get_session()
|
|
52
|
+
except AuthApiError:
|
|
53
|
+
# the server rejected the stored refresh token (revoked, reused, expired): it is dead
|
|
54
|
+
self.storage.clear()
|
|
55
|
+
return False
|
|
56
|
+
except Exception:
|
|
57
|
+
# network hiccup, DNS, timeout: keep the file — the next launch may well succeed
|
|
58
|
+
return False
|
|
59
|
+
if session is None:
|
|
60
|
+
return False
|
|
61
|
+
self.user_id = session.user.id
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
async def close(self) -> None:
|
|
65
|
+
for ch in (self._channel, self._signal_channel):
|
|
66
|
+
if ch is not None:
|
|
67
|
+
try:
|
|
68
|
+
await self.client.remove_channel(ch)
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
self._channel = self._signal_channel = None
|
|
72
|
+
|
|
73
|
+
# ---- auth: an invite code is the only credential -----------------------------------
|
|
74
|
+
|
|
75
|
+
async def redeem(self, code: str, display_name: str | None = None) -> dict:
|
|
76
|
+
"""Redeem a code through the Edge Function.
|
|
77
|
+
|
|
78
|
+
Signed out: a *friend* code creates the account (needs display_name) and signs in; a
|
|
79
|
+
*device* code signs in as the code's issuer. Signed in: a friend code adds a friend.
|
|
80
|
+
"""
|
|
81
|
+
body: dict[str, Any] = {"code": code.strip().upper()}
|
|
82
|
+
if display_name:
|
|
83
|
+
body["display_name"] = display_name.strip()
|
|
84
|
+
res = await self.client.functions.invoke("redeem-invite", {"body": body, "responseType": "json"})
|
|
85
|
+
if isinstance(res, dict) and res.get("token_hash"):
|
|
86
|
+
auth = await self.client.auth.verify_otp({"token_hash": res["token_hash"], "type": "magiclink"})
|
|
87
|
+
self.user_id = auth.user.id
|
|
88
|
+
return res
|
|
89
|
+
|
|
90
|
+
async def livekit_token(self, friend_id: str) -> dict:
|
|
91
|
+
"""{url, room, token, identity} for a call with a friend — minted by the Edge Function."""
|
|
92
|
+
return await self.client.functions.invoke(
|
|
93
|
+
"livekit-token", {"body": {"friend_id": friend_id}, "responseType": "json"}
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
async def sign_out(self, everywhere: bool = False) -> None:
|
|
97
|
+
try:
|
|
98
|
+
await self.client.auth.sign_out({"scope": "global" if everywhere else "local"})
|
|
99
|
+
finally:
|
|
100
|
+
self.storage.clear()
|
|
101
|
+
self.user_id = None
|
|
102
|
+
|
|
103
|
+
# ---- data ------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
async def me(self) -> Friend:
|
|
106
|
+
row = (await self.client.table("profiles").select("id, display_name").eq("id", self.user_id).single().execute()).data
|
|
107
|
+
return Friend(row["id"], row["display_name"])
|
|
108
|
+
|
|
109
|
+
async def friends(self) -> list[Friend]:
|
|
110
|
+
rows = (await self.client.table("friendships").select("user_a, user_b, blocked_by").execute()).data
|
|
111
|
+
ids = [r["user_b"] if r["user_a"] == self.user_id else r["user_a"] for r in rows if r["blocked_by"] is None]
|
|
112
|
+
if not ids:
|
|
113
|
+
return []
|
|
114
|
+
profiles = (await self.client.table("profiles").select("id, display_name").in_("id", ids).execute()).data
|
|
115
|
+
return sorted((Friend(p["id"], p["display_name"]) for p in profiles), key=lambda f: f.name.lower())
|
|
116
|
+
|
|
117
|
+
async def latest_per_friend(self, limit: int = 300) -> dict[str, Message]:
|
|
118
|
+
"""Most recent message per friend, for the sidebar previews. One query."""
|
|
119
|
+
me = self.user_id
|
|
120
|
+
rows = (
|
|
121
|
+
await self.client.table("messages").select("*")
|
|
122
|
+
.or_(f"sender_id.eq.{me},recipient_id.eq.{me}")
|
|
123
|
+
.order("created_at", desc=True).limit(limit).execute()
|
|
124
|
+
).data
|
|
125
|
+
latest: dict[str, Message] = {}
|
|
126
|
+
for r in rows:
|
|
127
|
+
other = r["recipient_id"] if r["sender_id"] == me else r["sender_id"]
|
|
128
|
+
latest.setdefault(other, Message(**r))
|
|
129
|
+
return latest
|
|
130
|
+
|
|
131
|
+
async def issue_invite(self, kind: str = "friend") -> str:
|
|
132
|
+
"""kind: 'friend' lets one person into your circle; 'device' signs *you* in elsewhere."""
|
|
133
|
+
row = (await self.client.table("invites").insert({"inviter_id": self.user_id, "kind": kind}).execute()).data[0]
|
|
134
|
+
return row["code"]
|
|
135
|
+
|
|
136
|
+
async def open_invites(self) -> list[dict]:
|
|
137
|
+
return (
|
|
138
|
+
await self.client.table("invites").select("code, kind, created_at, expires_at")
|
|
139
|
+
.is_("redeemed_at", "null").is_("revoked_at", "null").order("created_at", desc=True).execute()
|
|
140
|
+
).data
|
|
141
|
+
|
|
142
|
+
async def revoke_invite(self, code: str) -> None:
|
|
143
|
+
await self.client.table("invites").update({"revoked_at": "now()"}).eq("code", code).execute()
|
|
144
|
+
|
|
145
|
+
async def history(self, friend_id: str, limit: int = 200) -> list[Message]:
|
|
146
|
+
me, them = self.user_id, friend_id
|
|
147
|
+
rows = (
|
|
148
|
+
await self.client.table("messages").select("*")
|
|
149
|
+
.or_(f"and(sender_id.eq.{me},recipient_id.eq.{them}),and(sender_id.eq.{them},recipient_id.eq.{me})")
|
|
150
|
+
.order("created_at", desc=True).limit(limit).execute()
|
|
151
|
+
).data
|
|
152
|
+
return [Message(**r) for r in reversed(rows)]
|
|
153
|
+
|
|
154
|
+
async def send(self, friend_id: str, body: str) -> Message:
|
|
155
|
+
row = (await self.client.table("messages").insert(
|
|
156
|
+
{"sender_id": self.user_id, "recipient_id": friend_id, "body": body}
|
|
157
|
+
).execute()).data[0]
|
|
158
|
+
return Message(**row)
|
|
159
|
+
|
|
160
|
+
async def unfriend(self, friend_id: str) -> None:
|
|
161
|
+
a, b = sorted([self.user_id, friend_id])
|
|
162
|
+
await self.client.table("friendships").delete().eq("user_a", a).eq("user_b", b).execute()
|
|
163
|
+
|
|
164
|
+
# ---- realtime --------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
async def subscribe(
|
|
167
|
+
self,
|
|
168
|
+
on_message: Callable[[Message], Any],
|
|
169
|
+
on_friends_changed: Callable[[], Any],
|
|
170
|
+
on_presence: Callable[[set[str]], Any] | None = None,
|
|
171
|
+
on_signal: Callable[[str, dict, str], Any] | None = None,
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Live inserts addressed to me, changes to my friendships, who is online, and call
|
|
174
|
+
signals addressed to me. RLS applies to the table changes; presence and signals ride
|
|
175
|
+
public channels (uuids only) — private channels are the upgrade path, see [2.3]."""
|
|
176
|
+
me = self.user_id
|
|
177
|
+
ch = self.client.channel("circle", {"config": {"presence": {"key": me}}})
|
|
178
|
+
ch.on_postgres_changes(
|
|
179
|
+
"INSERT", schema="public", table="messages",
|
|
180
|
+
filter=f"recipient_id=eq.{me}",
|
|
181
|
+
callback=lambda payload: on_message(Message(**payload["data"]["record"])),
|
|
182
|
+
)
|
|
183
|
+
ch.on_postgres_changes(
|
|
184
|
+
"*", schema="public", table="friendships",
|
|
185
|
+
callback=lambda _payload: on_friends_changed(),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def _presence_sync() -> None:
|
|
189
|
+
self.online = {
|
|
190
|
+
p.get("user_id") for entries in ch.presence_state().values() for p in entries if p.get("user_id")
|
|
191
|
+
}
|
|
192
|
+
if on_presence:
|
|
193
|
+
on_presence(set(self.online))
|
|
194
|
+
|
|
195
|
+
ch.on_presence_sync(_presence_sync)
|
|
196
|
+
await self._subscribed(ch)
|
|
197
|
+
await ch.track({"user_id": me})
|
|
198
|
+
self._channel = ch
|
|
199
|
+
|
|
200
|
+
if on_signal:
|
|
201
|
+
sig = self.client.channel(f"sig:{me}", {"config": {"broadcast": {"self": False}}})
|
|
202
|
+
|
|
203
|
+
def _on_signal(wrapper: dict) -> None: # realtime hands over {"event", "payload"}
|
|
204
|
+
p = wrapper.get("payload") or {}
|
|
205
|
+
on_signal(p.get("event", ""), p, p.get("from", ""))
|
|
206
|
+
|
|
207
|
+
sig.on_broadcast("signal", _on_signal)
|
|
208
|
+
await self._subscribed(sig)
|
|
209
|
+
self._signal_channel = sig
|
|
210
|
+
|
|
211
|
+
async def send_signal(self, to: str, event: str, data: dict) -> None:
|
|
212
|
+
"""Broadcast a call signal onto the recipient's `sig:<id>` topic through Realtime's REST
|
|
213
|
+
endpoint — no channel join, works across sockets, one HTTP call."""
|
|
214
|
+
session = await self.client.auth.get_session()
|
|
215
|
+
async with httpx.AsyncClient(timeout=10) as http:
|
|
216
|
+
r = await http.post(
|
|
217
|
+
f"{SUPABASE_URL}/realtime/v1/api/broadcast",
|
|
218
|
+
headers={"apikey": SUPABASE_KEY, "Authorization": f"Bearer {session.access_token}",
|
|
219
|
+
"content-type": "application/json"},
|
|
220
|
+
json={"messages": [{"topic": f"sig:{to}", "event": "signal",
|
|
221
|
+
"payload": {"event": event, "from": self.user_id, **data}}]},
|
|
222
|
+
)
|
|
223
|
+
r.raise_for_status()
|
|
224
|
+
|
|
225
|
+
async def _subscribed(self, ch) -> None:
|
|
226
|
+
"""Subscribe and wait for the server's ack; the postgres_changes binding is confirmed here."""
|
|
227
|
+
ready: asyncio.Future = asyncio.get_running_loop().create_future()
|
|
228
|
+
|
|
229
|
+
def _status(status: RealtimeSubscribeStates, err: Exception | None) -> None:
|
|
230
|
+
if ready.done():
|
|
231
|
+
return
|
|
232
|
+
if status == RealtimeSubscribeStates.SUBSCRIBED:
|
|
233
|
+
ready.set_result(True)
|
|
234
|
+
elif status in (RealtimeSubscribeStates.CHANNEL_ERROR, RealtimeSubscribeStates.TIMED_OUT):
|
|
235
|
+
ready.set_exception(RuntimeError(f"realtime {status.value}: {err}"))
|
|
236
|
+
|
|
237
|
+
await ch.subscribe(_status)
|
|
238
|
+
await asyncio.wait_for(ready, 20)
|
|
239
|
+
# ponytail: Realtime's WAL poller picks a new binding up ~1s after the SUBSCRIBED ack;
|
|
240
|
+
# an insert in that gap is silently lost. A settle beats a resync protocol; raise if flaky.
|
|
241
|
+
await asyncio.sleep(1.5)
|