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.
@@ -0,0 +1,8 @@
1
+ .serena/
2
+ .env
3
+ .venv/
4
+ __pycache__/
5
+ *.pyc
6
+ supabase/.temp/
7
+ dist/
8
+ .gitnexus/
@@ -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,6 @@
1
+ # circle
2
+
3
+ Chat with your circle of friends, entirely in the terminal.
4
+
5
+ uv tool install circle-chat
6
+ 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)