parthmax24 commited on
Commit
241535f
·
1 Parent(s): 91db40f
.env.example ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Supabase project URL from Project Settings -> Data API
2
+ SUPABASE_URL=https://your-project-ref.supabase.co
3
+
4
+ # Server-only Supabase secret key from Project Settings -> API Keys -> Secret keys
5
+ # Never expose this key in frontend JavaScript or commit your real .env file.
6
+ SUPABASE_SERVICE_ROLE_KEY=your-supabase-secret-key
7
+
8
+ # Public Supabase Storage bucket used for chat file uploads
9
+ SUPABASE_BUCKET=chat-files
10
+
11
+ # How long messages and uploaded files should stay available
12
+ ROOM_HISTORY_HOURS=5
13
+
14
+ # How long room history is cached in app memory before refetching
15
+ HISTORY_CACHE_SECONDS=30
16
+
17
+ # How often the app checks for expired messages/files to delete
18
+ CLEANUP_INTERVAL_SECONDS=600
19
+
20
+ # Local/server port for the FastAPI app
21
+ PORT=7860
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ .env
2
+ __pycache__/
3
+ *.pyc
4
+ uvicorn.out.log
5
+ uvicorn.err.log
6
+ server.log
README.md CHANGED
@@ -1,6 +1,6 @@
1
  ---
2
  title: TriChat
3
- emoji: 💬
4
  colorFrom: indigo
5
  colorTo: pink
6
  sdk: docker
@@ -8,3 +8,328 @@ sdk_version: '1.0'
8
  app_file: Dockerfile
9
  pinned: false
10
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: TriChat
3
+ emoji: chat
4
  colorFrom: indigo
5
  colorTo: pink
6
  sdk: docker
 
8
  app_file: Dockerfile
9
  pinned: false
10
  ---
11
+
12
+ <div align="center">
13
+
14
+ # TriChat
15
+
16
+ ### Temporary anonymous rooms for quick file sharing between devices.
17
+
18
+ No account. No phone login. No personal messenger on office PCs.
19
+
20
+ **Open a room. Share what you need. Everything clears after 5 hours.**
21
+
22
+ [Live Demo](#) . [Quick Start](#quick-start) . [Why It Exists](#the-little-office-problem) . [Architecture](#how-it-works)
23
+
24
+ </div>
25
+
26
+ ![TriChat hero banner](docs/images/trichat-hero-banner.png)
27
+
28
+ ```text
29
+ [ PC-1 ] ---- room: project-drop ---- [ PC-2 ]
30
+ \ /
31
+ \---- files, links, notes, images ---/
32
+
33
+ temporary by design: 5 hours
34
+ ```
35
+
36
+ > Screenshot/GIF idea: add a short demo here showing two browser windows joining the same room and sharing a file.
37
+
38
+ ---
39
+
40
+ ## The Little Office Problem
41
+
42
+ ![The little office problem](docs/images/office-problem-scene.png)
43
+
44
+ Scene: three friends at work. Three computers. One tiny task.
45
+
46
+ > "Can you send me that file?"
47
+
48
+ > "Sure. Wait... should I log into WhatsApp Web on your PC?"
49
+
50
+ > "Maybe email?"
51
+
52
+ > "No no, I don't want my personal account open here."
53
+
54
+ And suddenly, sharing one small file becomes a weird little ritual:
55
+
56
+ - open personal messenger
57
+ - scan QR code
58
+ - wait for sync
59
+ - remember to log out
60
+ - hope nothing private stays open
61
+
62
+ So TriChat started as a tiny escape hatch.
63
+
64
+ Not a social network.
65
+ Not a permanent chat app.
66
+ Just a quick temporary room where teammates can drop files, links, and notes without logging into personal accounts.
67
+
68
+ ---
69
+
70
+ ## The Idea
71
+
72
+ ![The idea moment](docs/images/glowing-room-idea.png)
73
+
74
+ ```text
75
+ What if sharing between office PCs felt like passing a sticky note?
76
+
77
+ 1. Create a room
78
+ 2. Tell your friend the room name
79
+ 3. Drop files, links, or text
80
+ 4. Leave
81
+ 5. History disappears after 5 hours
82
+ ```
83
+
84
+ That is TriChat.
85
+
86
+ It is made for quick, low-friction sharing:
87
+
88
+ | Need | TriChat Answer |
89
+ | --- | --- |
90
+ | Move a file from one PC to another | Join the same room and upload it |
91
+ | Avoid logging into personal WhatsApp/email | No account needed |
92
+ | Share quick links or notes | Send them as messages |
93
+ | Avoid long-term clutter | Auto-clears after 5 hours |
94
+ | Use any device | Works in a browser |
95
+
96
+ ---
97
+
98
+ ## Features
99
+
100
+ - Anonymous rooms
101
+ - No signup, no phone, no QR login
102
+ - Text, links, images, and file sharing
103
+ - Real-time WebSocket chat
104
+ - Works across laptops, office PCs, lab machines, and phones
105
+ - 5-hour message and file expiry
106
+ - Small in-memory history cache for faster room loading
107
+ - Supabase-backed storage and database
108
+ - Docker-ready and self-hostable
109
+
110
+ ---
111
+
112
+ ## Story Mode Flow
113
+
114
+ ![TriChat sharing flow](docs/images/temp-room-flow.png)
115
+
116
+ ```mermaid
117
+ flowchart LR
118
+ A[Open TriChat] --> B[Enter name + room]
119
+ B --> C[Friend joins same room]
120
+ C --> D[Share text, links, images, files]
121
+ D --> E[Work gets done]
122
+ E --> F[History clears after 5 hours]
123
+ ```
124
+
125
+ If GitHub does not render Mermaid in your environment, replace this with an image from `docs/flow.png`.
126
+
127
+ ---
128
+
129
+ ## Quick Start
130
+
131
+ ```bash
132
+ git clone https://github.com/parthmax2/TriChat.git
133
+ cd TriChat
134
+ pip install -r requirements.txt
135
+ ```
136
+
137
+ Create your environment file:
138
+
139
+ ```bash
140
+ cp .env.example .env
141
+ ```
142
+
143
+ Fill in:
144
+
145
+ ```env
146
+ SUPABASE_URL=https://your-project-ref.supabase.co
147
+ SUPABASE_SERVICE_ROLE_KEY=your-supabase-secret-key
148
+ SUPABASE_BUCKET=chat-files
149
+ ROOM_HISTORY_HOURS=5
150
+ HISTORY_CACHE_SECONDS=30
151
+ CLEANUP_INTERVAL_SECONDS=600
152
+ PORT=7860
153
+ ```
154
+
155
+ Run it:
156
+
157
+ ```bash
158
+ uvicorn app:app --reload --port 7860
159
+ ```
160
+
161
+ Open:
162
+
163
+ ```text
164
+ http://127.0.0.1:7860
165
+ ```
166
+
167
+ ---
168
+
169
+ ## Setup
170
+
171
+ ### 1. Create The Database
172
+
173
+ In your Supabase SQL editor, run:
174
+
175
+ ```text
176
+ supabase_schema.sql
177
+ ```
178
+
179
+ This creates the `messages` table and adds:
180
+
181
+ - `expires_at` for 5-hour cleanup
182
+ - `file_path` so uploaded files can be deleted from storage
183
+ - indexes for faster room history
184
+
185
+ ### 2. Create The File Bucket
186
+
187
+ Create a public storage bucket named:
188
+
189
+ ```text
190
+ chat-files
191
+ ```
192
+
193
+ TriChat stores uploaded files there and deletes expired file objects during cleanup.
194
+
195
+ ### 3. Add Environment Variables
196
+
197
+ Use `.env.example` as your guide.
198
+
199
+ Never commit your real `.env` file.
200
+
201
+ ---
202
+
203
+ ## How It Works
204
+
205
+ ```text
206
+ Browser
207
+ |
208
+ | WebSocket messages
209
+ v
210
+ FastAPI app
211
+ |
212
+ | save messages / fetch room history
213
+ v
214
+ Supabase Postgres
215
+ |
216
+ | upload files / delete expired files
217
+ v
218
+ Supabase Storage
219
+ ```
220
+
221
+ ### Temporary Cleanup
222
+
223
+ Every message gets an expiry time:
224
+
225
+ ```text
226
+ created_at + 5 hours = expires_at
227
+ ```
228
+
229
+ The app cleanup task runs every `CLEANUP_INTERVAL_SECONDS` and removes:
230
+
231
+ - expired database messages
232
+ - expired uploaded files
233
+ - stale cached history
234
+
235
+ ---
236
+
237
+ ## Test The Integration
238
+
239
+ Run:
240
+
241
+ ```bash
242
+ python test.py
243
+ ```
244
+
245
+ Expected result:
246
+
247
+ ```text
248
+ Supabase database integration successful.
249
+ Inserted, read, and deleted test row id: ...
250
+ ```
251
+
252
+ ---
253
+
254
+ ## Screenshots
255
+
256
+ Add these before launch:
257
+
258
+ | Screen | Preview |
259
+ | --- | --- |
260
+ | Join room | `docs/screenshots/join-room.png` |
261
+ | Two users chatting | `docs/screenshots/chat-room.png` |
262
+ | File upload | `docs/screenshots/file-share.png` |
263
+ | Mobile view | `docs/screenshots/mobile.png` |
264
+
265
+ > Tip: a 10-second GIF is more powerful than four static screenshots.
266
+
267
+ ---
268
+
269
+ ## Image Prompts
270
+
271
+ Use this shared visual style for every image so the README matches the app theme:
272
+
273
+ ```text
274
+ dreamy anime-style illustration, soft sky-blue and white glassy UI glow, pastel pink highlights, tiny mint-green accents, cozy modern office, clean rounded shapes, gentle bloom, cinematic lighting, professional but cute, no readable text, no watermark
275
+ ```
276
+
277
+ | File name | Where it appears | Prompt |
278
+ | --- | --- | --- |
279
+ | `docs/images/trichat-hero-banner.png` | Top hero banner | Three young office friends at separate computers in a cozy modern workplace, their screens connected by a soft glowing temporary chat room, floating files and notes moving between devices, white glass panels, sky-blue glow, pastel pink highlights, mint-green status dots, wide cinematic banner composition |
280
+ | `docs/images/office-problem-scene.png` | The Little Office Problem | One coworker hesitating before logging into a personal messenger on someone else's office PC, two friends waiting with a file, privacy concern shown with subtle lock shapes and a phone silhouette, awkward but cute expressions, soft blue-white office lighting, pastel pink monitor glow |
281
+ | `docs/images/glowing-room-idea.png` | The Idea | A small magical chat-room portal appearing between three computer screens, files, links, sticky notes, and image cards floating through it, coworkers smiling with relief, glassy white interface elements, blue glow, mint accents, dreamy startup energy |
282
+ | `docs/images/temp-room-flow.png` | Story Mode Flow | Two office computers connected through a temporary room bubble, files and messages traveling safely between screens, a gentle hourglass symbol showing 5-hour expiry, calm blue-white workspace, pastel pink rim light, clean readable composition with no text |
283
+ | `docs/images/auto-delete-scene.png` | Optional cleanup section image | A temporary chat room gently dissolving into sparkling light after 5 hours, old files and message cards fading like soft particles, hourglass glow, peaceful night office, blue-white base colors, pink highlights, mint safety accents |
284
+ | `docs/images/friends-success-scene.png` | Optional final CTA image | Three office friends smiling around glowing computer screens after sharing files successfully, calm satisfied mood, sunrise light, clean desks, blue-white glass UI, pastel pink warmth, tiny mint-green online indicators |
285
+
286
+ ---
287
+
288
+ ## Perfect For
289
+
290
+ - Office teammates sharing files across PCs
291
+ - Students in computer labs
292
+ - Hackathon teams
293
+ - Support desks
294
+ - Temporary project rooms
295
+ - People who do not want to log into personal messengers on shared machines
296
+
297
+ ---
298
+
299
+ ## Safety Note
300
+
301
+ TriChat is built for quick temporary exchange, not permanent private storage.
302
+
303
+ Do not share passwords, private keys, confidential company documents, or anything that should not appear in a public temporary room.
304
+
305
+ ---
306
+
307
+ ## The Short Story For GitHub
308
+
309
+ I built TriChat because my coworkers and I often needed to move files between office PCs.
310
+
311
+ Using WhatsApp Web was annoying because nobody wanted to log into a personal account on someone else's computer.
312
+
313
+ TriChat is a temporary anonymous room: open a room, share files or links, and the history clears after 5 hours.
314
+
315
+ ---
316
+
317
+ ## Roadmap
318
+
319
+ - Copy invite link button
320
+ - Room expiry countdown in the UI
321
+ - Drag-and-drop file upload
322
+ - Dark mode
323
+ - Optional room password
324
+ - Admin cleanup dashboard
325
+ - One-click deploy buttons
326
+
327
+ ---
328
+
329
+ <div align="center">
330
+
331
+ ### If TriChat saved you from logging into WhatsApp on a random PC, give it a star.
332
+
333
+ Temporary rooms. Quick sharing. No personal login.
334
+
335
+ </div>
app.py CHANGED
@@ -1,92 +1,378 @@
1
- from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException
2
- from fastapi.staticfiles import StaticFiles
3
- from fastapi.responses import HTMLResponse
4
- from fastapi.templating import Jinja2Templates
5
- import json
6
  import asyncio
7
- from datetime import datetime
8
- from typing import Dict, List, Set
9
  import base64
 
 
 
 
10
  import mimetypes
11
  import os
 
 
12
  import uvicorn
13
- app = FastAPI(title="Tri-Chat API", description="Real-time chat with WebSocket support")
 
 
 
 
 
 
 
 
14
 
15
- # Serve static assets
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  app.mount("/static", StaticFiles(directory="static"), name="static")
17
 
18
- # Setup templates
19
- templates = Jinja2Templates(directory="templates")
20
 
21
- # Connection Manager to handle WebSocket connections
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  class ConnectionManager:
23
  def __init__(self):
24
- # Store active connections by room
25
  self.active_connections: Dict[str, List[Dict]] = {}
26
- # Store message history by room (in-memory for demo)
27
- self.message_history: Dict[str, List[Dict]] = {}
28
-
29
  async def connect(self, websocket: WebSocket, room: str, username: str):
30
  await websocket.accept()
31
-
32
- # Initialize room if it doesn't exist
33
  if room not in self.active_connections:
34
  self.active_connections[room] = []
35
- self.message_history[room] = []
36
-
37
- # Add connection to room
38
- connection_info = {
39
- "websocket": websocket,
40
- "username": username,
41
- "joined_at": datetime.now().isoformat()
42
- }
43
- self.active_connections[room].append(connection_info)
44
 
45
- # Send message history to new user before broadcasting the fresh join event
46
- for message in self.message_history[room]:
 
 
 
 
 
 
 
47
  await websocket.send_text(json.dumps(message))
48
-
49
- # Send join notification
50
- join_message = {
51
- "type": "system",
52
- "message": f"{username} joined the room",
53
- "timestamp": datetime.now().isoformat(),
54
- "room": room
55
- }
56
- await self.broadcast_to_room(room, join_message)
 
 
57
  await self.broadcast_user_list(room)
58
-
59
- def disconnect(self, websocket: WebSocket, room: str):
60
- if room in self.active_connections:
61
- # Find and remove the connection
62
- for conn in self.active_connections[room]:
63
- if conn["websocket"] == websocket:
64
- self.active_connections[room].remove(conn)
65
- return conn["username"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  return None
67
-
68
- async def broadcast_to_room(self, room: str, message: dict):
 
 
 
 
 
 
 
 
 
 
 
 
69
  if room not in self.active_connections:
70
  return
71
-
72
- # Store message in history
73
- self.message_history[room].append(message)
74
-
75
- # Keep only last 100 messages per room
76
- if len(self.message_history[room]) > 100:
77
- self.message_history[room] = self.message_history[room][-100:]
78
-
79
- # Send to all connections in room
80
  disconnected = []
81
- for connection_info in self.active_connections[room]:
82
  try:
83
  await connection_info["websocket"].send_text(json.dumps(message))
84
- except:
85
  disconnected.append(connection_info)
86
-
87
- # Remove disconnected clients
88
  for conn in disconnected:
89
- self.active_connections[room].remove(conn)
 
 
 
 
 
90
 
91
  async def broadcast_user_list(self, room: str):
92
  if room not in self.active_connections:
@@ -95,162 +381,204 @@ class ConnectionManager:
95
  users_message = {
96
  "type": "user_list",
97
  "users": self.get_room_users(room),
98
- "room": room
99
  }
100
 
101
  disconnected = []
102
- for connection_info in self.active_connections[room]:
103
  try:
104
  await connection_info["websocket"].send_text(json.dumps(users_message))
105
- except:
106
  disconnected.append(connection_info)
107
 
108
  for conn in disconnected:
109
- self.active_connections[room].remove(conn)
110
-
 
111
  def get_room_users(self, room: str) -> List[str]:
112
  if room not in self.active_connections:
113
  return []
114
  return [conn["username"] for conn in self.active_connections[room]]
115
 
116
- # Global connection manager instance
 
 
 
117
  manager = ConnectionManager()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
 
119
  @app.get("/", response_class=HTMLResponse)
120
  async def get_chat_page():
121
- """Serve the chat HTML page"""
122
  try:
123
  with open("templates/index.html", "r", encoding="utf-8") as f:
124
- html_content = f.read()
125
- return HTMLResponse(content=html_content)
126
  except FileNotFoundError:
127
  return HTMLResponse(
128
- content="<h1>Error: templates/index.html not found</h1><p>Please make sure the templates directory exists with index.html</p>",
129
- status_code=404
130
  )
131
 
 
132
  @app.websocket("/ws/{room}")
133
  async def websocket_endpoint(websocket: WebSocket, room: str, username: str):
134
- """WebSocket endpoint for real-time chat"""
135
-
136
- # Validate inputs
137
- if not username or len(username.strip()) == 0:
138
  await websocket.close(code=1008, reason="Username is required")
139
  return
140
-
141
- if not room or len(room.strip()) == 0:
142
- room = "global"
143
-
144
- # Sanitize inputs
145
- username = username.strip()[:20] # Limit username length
146
- room = room.strip()[:30] # Limit room name length
147
-
148
  await manager.connect(websocket, room, username)
149
-
150
  try:
151
  while True:
152
- # Receive message from client
153
  data = await websocket.receive_text()
154
- message_data = json.loads(data)
155
-
156
- # Validate message type
157
- if message_data.get("type") not in ["text", "file"]:
 
158
  continue
159
-
160
- # Process text message
161
- if message_data["type"] == "text":
162
- text_content = message_data.get("text", "").strip()
163
- if len(text_content) == 0:
164
- continue
165
-
166
- # Sanitize and limit text length
167
- text_content = text_content[:500]
168
-
169
- message = {
170
- "type": "text",
171
- "username": username,
172
- "text": text_content,
173
- "timestamp": datetime.now().isoformat(),
174
- "room": room
175
- }
176
-
177
- await manager.broadcast_to_room(room, message)
178
-
179
- # Process file message
180
- elif message_data["type"] == "file":
181
- file_name = message_data.get("fileName", "unknown")[:100]
182
- file_type = message_data.get("fileType", "application/octet-stream")
183
- file_size = message_data.get("fileSize", 0)
184
- file_data = message_data.get("fileData", "")
185
-
186
- # Validate file size (5MB limit)
187
- if file_size > 5 * 1024 * 1024:
188
- await websocket.send_text(json.dumps({
189
- "type": "error",
190
- "message": "File size exceeds 5MB limit"
191
- }))
192
- continue
193
-
194
- # Validate base64 data
195
- try:
196
- base64.b64decode(file_data)
197
- except Exception:
198
- await websocket.send_text(json.dumps({
199
- "type": "error",
200
- "message": "Invalid file data"
201
- }))
202
- continue
203
-
204
- message = {
205
- "type": "file",
206
- "username": username,
207
- "fileName": file_name,
208
- "fileType": file_type,
209
- "fileSize": file_size,
210
- "fileData": file_data,
211
- "timestamp": datetime.now().isoformat(),
212
- "room": room
213
- }
214
-
215
- await manager.broadcast_to_room(room, message)
216
-
217
  except WebSocketDisconnect:
218
  disconnected_username = manager.disconnect(websocket, room)
219
  if disconnected_username:
220
- leave_message = {
221
- "type": "system",
222
- "message": f"{disconnected_username} left the room",
223
- "timestamp": datetime.now().isoformat(),
224
- "room": room
225
- }
226
- await manager.broadcast_to_room(room, leave_message)
 
 
 
227
  await manager.broadcast_user_list(room)
228
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  @app.get("/api/rooms")
230
  async def get_active_rooms():
231
- """Get list of active chat rooms"""
232
  rooms = []
233
  for room_name, connections in manager.active_connections.items():
234
- if connections: # Only include rooms with active connections
235
- rooms.append({
236
- "name": room_name,
237
- "user_count": len(connections),
238
- "users": [conn["username"] for conn in connections]
239
- })
 
 
240
  return {"rooms": rooms}
241
 
 
242
  @app.get("/api/rooms/{room}/users")
243
  async def get_room_users(room: str):
244
- """Get list of users in a specific room"""
245
  users = manager.get_room_users(room)
246
  return {
247
  "room": room,
248
  "users": users,
249
- "user_count": len(users)
250
  }
251
 
252
 
253
-
254
  if __name__ == "__main__":
255
- port = int(os.getenv("PORT", 7860)) # Hugging Face expects 7860
256
  uvicorn.run(app, host="0.0.0.0", port=port)
 
1
+ from datetime import datetime, timedelta, timezone
2
+ from pathlib import Path
3
+ from typing import Dict, List, Optional
4
+ from uuid import uuid4
 
5
  import asyncio
 
 
6
  import base64
7
+ import binascii
8
+ import contextlib
9
+ import json
10
+ import logging
11
  import mimetypes
12
  import os
13
+
14
+ import httpx
15
  import uvicorn
16
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
17
+ from fastapi.responses import HTMLResponse
18
+ from fastapi.staticfiles import StaticFiles
19
+
20
+
21
+ def load_env_file(path: str = ".env") -> None:
22
+ env_path = Path(path)
23
+ if not env_path.exists():
24
+ return
25
 
26
+ for raw_line in env_path.read_text(encoding="utf-8").splitlines():
27
+ line = raw_line.strip()
28
+ if not line or line.startswith("#") or "=" not in line:
29
+ continue
30
+
31
+ key, value = line.split("=", 1)
32
+ os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
33
+
34
+
35
+ load_env_file()
36
+
37
+ logging.basicConfig(level=logging.INFO)
38
+ logger = logging.getLogger("trichat")
39
+
40
+ MAX_HISTORY_MESSAGES = 100
41
+ MAX_FILE_SIZE = 5 * 1024 * 1024
42
+ ROOM_HISTORY_HOURS = int(os.getenv("ROOM_HISTORY_HOURS", "5"))
43
+ HISTORY_CACHE_SECONDS = int(os.getenv("HISTORY_CACHE_SECONDS", "30"))
44
+ CLEANUP_INTERVAL_SECONDS = int(os.getenv("CLEANUP_INTERVAL_SECONDS", "600"))
45
+ SUPABASE_URL = os.getenv("SUPABASE_URL", "").rstrip("/")
46
+ SUPABASE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "")
47
+ SUPABASE_BUCKET = os.getenv("SUPABASE_BUCKET", "chat-files")
48
+
49
+ app = FastAPI(title="Tri-Chat API", description="Temporary anonymous chat rooms")
50
  app.mount("/static", StaticFiles(directory="static"), name="static")
51
 
 
 
52
 
53
+ def now_utc() -> datetime:
54
+ return datetime.now(timezone.utc)
55
+
56
+
57
+ def utc_now() -> str:
58
+ return now_utc().isoformat()
59
+
60
+
61
+ def expiry_time() -> str:
62
+ return (now_utc() + timedelta(hours=ROOM_HISTORY_HOURS)).isoformat()
63
+
64
+
65
+ def clean_text(value: object, limit: int, default: str = "") -> str:
66
+ if not isinstance(value, str):
67
+ return default
68
+ return value.strip()[:limit]
69
+
70
+
71
+ def safe_file_name(file_name: str) -> str:
72
+ cleaned = Path(file_name).name.strip()[:100]
73
+ return cleaned or "upload.bin"
74
+
75
+
76
+ def storage_path_for(room: str, file_name: str) -> str:
77
+ room_prefix = "".join(char if char.isalnum() or char in ("-", "_") else "_" for char in room)
78
+ return f"{room_prefix}/{uuid4().hex}-{safe_file_name(file_name)}"
79
+
80
+
81
+ def parse_iso_datetime(value: str) -> Optional[datetime]:
82
+ if not value:
83
+ return None
84
+
85
+ try:
86
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
87
+ except ValueError:
88
+ return None
89
+
90
+
91
+ def is_expired(message: dict) -> bool:
92
+ expires_at = parse_iso_datetime(message.get("expiresAt", ""))
93
+ return bool(expires_at and expires_at <= now_utc())
94
+
95
+
96
+ class SupabaseStore:
97
+ def __init__(self, url: str, key: str, bucket: str):
98
+ self.url = url
99
+ self.key = key
100
+ self.bucket = bucket
101
+
102
+ @property
103
+ def enabled(self) -> bool:
104
+ return bool(self.url and self.key)
105
+
106
+ @property
107
+ def headers(self) -> Dict[str, str]:
108
+ return {
109
+ "apikey": self.key,
110
+ "Authorization": f"Bearer {self.key}",
111
+ }
112
+
113
+ async def fetch_history(self, room: str) -> List[dict]:
114
+ if not self.enabled:
115
+ return []
116
+
117
+ params = {
118
+ "select": "*",
119
+ "room": f"eq.{room}",
120
+ "expires_at": f"gt.{utc_now()}",
121
+ "order": "created_at.desc",
122
+ "limit": str(MAX_HISTORY_MESSAGES),
123
+ }
124
+
125
+ async with httpx.AsyncClient(timeout=10) as client:
126
+ response = await client.get(
127
+ f"{self.url}/rest/v1/messages",
128
+ headers=self.headers,
129
+ params=params,
130
+ )
131
+ response.raise_for_status()
132
+
133
+ rows = list(reversed(response.json()))
134
+ return [self.row_to_message(row) for row in rows]
135
+
136
+ async def save_message(self, message: dict) -> None:
137
+ if not self.enabled or message.get("type") == "system":
138
+ return
139
+
140
+ payload = {
141
+ "room": message["room"],
142
+ "username": message["username"],
143
+ "message_type": message["type"],
144
+ "text": message.get("text"),
145
+ "file_url": message.get("fileUrl"),
146
+ "file_path": message.get("filePath"),
147
+ "file_name": message.get("fileName"),
148
+ "file_type": message.get("fileType"),
149
+ "file_size": message.get("fileSize"),
150
+ "created_at": message["timestamp"],
151
+ "expires_at": message["expiresAt"],
152
+ }
153
+
154
+ async with httpx.AsyncClient(timeout=10) as client:
155
+ response = await client.post(
156
+ f"{self.url}/rest/v1/messages",
157
+ headers={**self.headers, "Content-Type": "application/json"},
158
+ json=payload,
159
+ )
160
+ response.raise_for_status()
161
+
162
+ async def upload_file(self, room: str, file_name: str, file_type: str, file_bytes: bytes) -> tuple[str, str]:
163
+ if not self.enabled:
164
+ raise RuntimeError("Supabase is not configured")
165
+
166
+ path = storage_path_for(room, file_name)
167
+ content_type = file_type or mimetypes.guess_type(file_name)[0] or "application/octet-stream"
168
+
169
+ async with httpx.AsyncClient(timeout=30) as client:
170
+ response = await client.post(
171
+ f"{self.url}/storage/v1/object/{self.bucket}/{path}",
172
+ headers={
173
+ **self.headers,
174
+ "Content-Type": content_type,
175
+ "x-upsert": "false",
176
+ },
177
+ content=file_bytes,
178
+ )
179
+ response.raise_for_status()
180
+
181
+ public_url = f"{self.url}/storage/v1/object/public/{self.bucket}/{path}"
182
+ return public_url, path
183
+
184
+ async def delete_storage_objects(self, paths: List[str]) -> None:
185
+ if not self.enabled or not paths:
186
+ return
187
+
188
+ async with httpx.AsyncClient(timeout=30) as client:
189
+ response = await client.request(
190
+ "DELETE",
191
+ f"{self.url}/storage/v1/object/{self.bucket}",
192
+ headers={**self.headers, "Content-Type": "application/json"},
193
+ json={"prefixes": paths},
194
+ )
195
+ response.raise_for_status()
196
+
197
+ async def cleanup_expired(self) -> int:
198
+ if not self.enabled:
199
+ return 0
200
+
201
+ current_time = utc_now()
202
+ async with httpx.AsyncClient(timeout=30) as client:
203
+ select_response = await client.get(
204
+ f"{self.url}/rest/v1/messages",
205
+ headers=self.headers,
206
+ params={
207
+ "select": "id,file_path",
208
+ "expires_at": f"lte.{current_time}",
209
+ "limit": "500",
210
+ },
211
+ )
212
+ select_response.raise_for_status()
213
+ expired_rows = select_response.json()
214
+
215
+ if not expired_rows:
216
+ return 0
217
+
218
+ file_paths = [row["file_path"] for row in expired_rows if row.get("file_path")]
219
+ if file_paths:
220
+ await self.delete_storage_objects(file_paths)
221
+
222
+ delete_response = await client.delete(
223
+ f"{self.url}/rest/v1/messages",
224
+ headers=self.headers,
225
+ params={"expires_at": f"lte.{current_time}"},
226
+ )
227
+ delete_response.raise_for_status()
228
+
229
+ return len(expired_rows)
230
+
231
+ def row_to_message(self, row: dict) -> dict:
232
+ message = {
233
+ "type": row["message_type"],
234
+ "username": row["username"],
235
+ "timestamp": row["created_at"],
236
+ "expiresAt": row["expires_at"],
237
+ "room": row["room"],
238
+ }
239
+
240
+ if row["message_type"] == "text":
241
+ message["text"] = row.get("text") or ""
242
+ elif row["message_type"] == "file":
243
+ message.update(
244
+ {
245
+ "fileUrl": row.get("file_url") or "",
246
+ "fileName": row.get("file_name") or "download",
247
+ "fileType": row.get("file_type") or "application/octet-stream",
248
+ "fileSize": row.get("file_size") or 0,
249
+ }
250
+ )
251
+
252
+ return message
253
+
254
+
255
+ store = SupabaseStore(SUPABASE_URL, SUPABASE_KEY, SUPABASE_BUCKET)
256
+
257
+
258
  class ConnectionManager:
259
  def __init__(self):
 
260
  self.active_connections: Dict[str, List[Dict]] = {}
261
+ self.fallback_history: Dict[str, List[Dict]] = {}
262
+ self.history_cache: Dict[str, Dict] = {}
263
+
264
  async def connect(self, websocket: WebSocket, room: str, username: str):
265
  await websocket.accept()
266
+
 
267
  if room not in self.active_connections:
268
  self.active_connections[room] = []
269
+ self.fallback_history[room] = []
 
 
 
 
 
 
 
 
270
 
271
+ self.active_connections[room].append(
272
+ {
273
+ "websocket": websocket,
274
+ "username": username,
275
+ "joined_at": utc_now(),
276
+ }
277
+ )
278
+
279
+ for message in await self.get_history(room):
280
  await websocket.send_text(json.dumps(message))
281
+
282
+ await self.broadcast_to_room(
283
+ room,
284
+ {
285
+ "type": "system",
286
+ "message": f"{username} joined the room",
287
+ "timestamp": utc_now(),
288
+ "room": room,
289
+ },
290
+ persist=False,
291
+ )
292
  await self.broadcast_user_list(room)
293
+
294
+ async def get_history(self, room: str) -> List[dict]:
295
+ self.prune_fallback_history(room)
296
+ cached = self.history_cache.get(room)
297
+ if cached and cached["expires_at"] > now_utc():
298
+ return cached["messages"]
299
+
300
+ if store.enabled:
301
+ try:
302
+ messages = await store.fetch_history(room)
303
+ self.cache_history(room, messages)
304
+ return messages
305
+ except httpx.HTTPError as exc:
306
+ logger.warning("Could not fetch Supabase history: %s", exc)
307
+
308
+ messages = self.fallback_history.get(room, [])
309
+ self.cache_history(room, messages)
310
+ return messages
311
+
312
+ def cache_history(self, room: str, messages: List[dict]) -> None:
313
+ self.history_cache[room] = {
314
+ "messages": [message for message in messages if not is_expired(message)],
315
+ "expires_at": now_utc() + timedelta(seconds=HISTORY_CACHE_SECONDS),
316
+ }
317
+
318
+ def append_to_cache(self, room: str, message: dict) -> None:
319
+ cached = self.history_cache.get(room)
320
+ if not cached:
321
+ return
322
+
323
+ cached["messages"].append(message)
324
+ cached["messages"] = cached["messages"][-MAX_HISTORY_MESSAGES:]
325
+
326
+ def prune_fallback_history(self, room: str) -> None:
327
+ if room in self.fallback_history:
328
+ self.fallback_history[room] = [
329
+ message for message in self.fallback_history[room] if not is_expired(message)
330
+ ][-MAX_HISTORY_MESSAGES:]
331
+
332
+ def disconnect(self, websocket: WebSocket, room: str) -> Optional[str]:
333
+ if room not in self.active_connections:
334
+ return None
335
+
336
+ for conn in list(self.active_connections[room]):
337
+ if conn["websocket"] == websocket:
338
+ self.active_connections[room].remove(conn)
339
+ username = conn["username"]
340
+ if not self.active_connections[room]:
341
+ self.active_connections.pop(room, None)
342
+ return username
343
+
344
  return None
345
+
346
+ async def broadcast_to_room(self, room: str, message: dict, persist: bool = True):
347
+ if persist:
348
+ if store.enabled:
349
+ try:
350
+ await store.save_message(message)
351
+ except httpx.HTTPError as exc:
352
+ logger.warning("Could not save message to Supabase: %s", exc)
353
+ self.add_fallback_message(room, message)
354
+ else:
355
+ self.add_fallback_message(room, message)
356
+
357
+ self.append_to_cache(room, message)
358
+
359
  if room not in self.active_connections:
360
  return
361
+
 
 
 
 
 
 
 
 
362
  disconnected = []
363
+ for connection_info in list(self.active_connections[room]):
364
  try:
365
  await connection_info["websocket"].send_text(json.dumps(message))
366
+ except RuntimeError:
367
  disconnected.append(connection_info)
368
+
 
369
  for conn in disconnected:
370
+ if conn in self.active_connections.get(room, []):
371
+ self.active_connections[room].remove(conn)
372
+
373
+ def add_fallback_message(self, room: str, message: dict) -> None:
374
+ self.fallback_history.setdefault(room, []).append(message)
375
+ self.prune_fallback_history(room)
376
 
377
  async def broadcast_user_list(self, room: str):
378
  if room not in self.active_connections:
 
381
  users_message = {
382
  "type": "user_list",
383
  "users": self.get_room_users(room),
384
+ "room": room,
385
  }
386
 
387
  disconnected = []
388
+ for connection_info in list(self.active_connections[room]):
389
  try:
390
  await connection_info["websocket"].send_text(json.dumps(users_message))
391
+ except RuntimeError:
392
  disconnected.append(connection_info)
393
 
394
  for conn in disconnected:
395
+ if conn in self.active_connections.get(room, []):
396
+ self.active_connections[room].remove(conn)
397
+
398
  def get_room_users(self, room: str) -> List[str]:
399
  if room not in self.active_connections:
400
  return []
401
  return [conn["username"] for conn in self.active_connections[room]]
402
 
403
+ def clear_cache(self) -> None:
404
+ self.history_cache.clear()
405
+
406
+
407
  manager = ConnectionManager()
408
+ cleanup_task: Optional[asyncio.Task] = None
409
+
410
+
411
+ async def cleanup_loop() -> None:
412
+ while True:
413
+ try:
414
+ deleted_count = await store.cleanup_expired()
415
+ if deleted_count:
416
+ manager.clear_cache()
417
+ logger.info("Deleted %s expired messages/files", deleted_count)
418
+ except httpx.HTTPError as exc:
419
+ logger.warning("Expired cleanup failed: %s", exc)
420
+
421
+ await asyncio.sleep(CLEANUP_INTERVAL_SECONDS)
422
+
423
+
424
+ @app.on_event("startup")
425
+ async def startup_event():
426
+ global cleanup_task
427
+ if store.enabled:
428
+ cleanup_task = asyncio.create_task(cleanup_loop())
429
+ logger.info("Temporary cleanup is running every %s seconds", CLEANUP_INTERVAL_SECONDS)
430
+ else:
431
+ logger.warning("Supabase is not configured. Using in-memory fallback only.")
432
+
433
+
434
+ @app.on_event("shutdown")
435
+ async def shutdown_event():
436
+ if cleanup_task:
437
+ cleanup_task.cancel()
438
+ with contextlib.suppress(asyncio.CancelledError):
439
+ await cleanup_task
440
+
441
 
442
  @app.get("/", response_class=HTMLResponse)
443
  async def get_chat_page():
 
444
  try:
445
  with open("templates/index.html", "r", encoding="utf-8") as f:
446
+ return HTMLResponse(content=f.read())
 
447
  except FileNotFoundError:
448
  return HTMLResponse(
449
+ content="<h1>Error: templates/index.html not found</h1>",
450
+ status_code=404,
451
  )
452
 
453
+
454
  @app.websocket("/ws/{room}")
455
  async def websocket_endpoint(websocket: WebSocket, room: str, username: str):
456
+ username = clean_text(username, 20)
457
+ room = clean_text(room, 30, "global") or "global"
458
+
459
+ if not username:
460
  await websocket.close(code=1008, reason="Username is required")
461
  return
462
+
 
 
 
 
 
 
 
463
  await manager.connect(websocket, room, username)
464
+
465
  try:
466
  while True:
 
467
  data = await websocket.receive_text()
468
+
469
+ try:
470
+ message_data = json.loads(data)
471
+ except json.JSONDecodeError:
472
+ await websocket.send_text(json.dumps({"type": "error", "message": "Invalid message"}))
473
  continue
474
+
475
+ message_type = message_data.get("type")
476
+ if message_type == "text":
477
+ await handle_text_message(room, username, message_data)
478
+ elif message_type == "file":
479
+ await handle_file_message(websocket, room, username, message_data)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
480
  except WebSocketDisconnect:
481
  disconnected_username = manager.disconnect(websocket, room)
482
  if disconnected_username:
483
+ await manager.broadcast_to_room(
484
+ room,
485
+ {
486
+ "type": "system",
487
+ "message": f"{disconnected_username} left the room",
488
+ "timestamp": utc_now(),
489
+ "room": room,
490
+ },
491
+ persist=False,
492
+ )
493
  await manager.broadcast_user_list(room)
494
 
495
+
496
+ async def handle_text_message(room: str, username: str, message_data: dict):
497
+ text_content = clean_text(message_data.get("text"), 500)
498
+ if not text_content:
499
+ return
500
+
501
+ await manager.broadcast_to_room(
502
+ room,
503
+ {
504
+ "type": "text",
505
+ "username": username,
506
+ "text": text_content,
507
+ "timestamp": utc_now(),
508
+ "expiresAt": expiry_time(),
509
+ "room": room,
510
+ },
511
+ )
512
+
513
+
514
+ async def handle_file_message(websocket: WebSocket, room: str, username: str, message_data: dict):
515
+ file_name = safe_file_name(clean_text(message_data.get("fileName"), 100, "upload.bin"))
516
+ file_type = clean_text(message_data.get("fileType"), 120, "application/octet-stream")
517
+ file_data = message_data.get("fileData", "")
518
+
519
+ if not store.enabled:
520
+ await websocket.send_text(json.dumps({"type": "error", "message": "File uploads need Supabase configured"}))
521
+ return
522
+
523
+ try:
524
+ file_bytes = base64.b64decode(file_data, validate=True)
525
+ except (binascii.Error, TypeError):
526
+ await websocket.send_text(json.dumps({"type": "error", "message": "Invalid file data"}))
527
+ return
528
+
529
+ if len(file_bytes) > MAX_FILE_SIZE:
530
+ await websocket.send_text(json.dumps({"type": "error", "message": "File size exceeds 5MB limit"}))
531
+ return
532
+
533
+ try:
534
+ file_url, file_path = await store.upload_file(room, file_name, file_type, file_bytes)
535
+ except httpx.HTTPError as exc:
536
+ logger.warning("File upload failed: %s", exc)
537
+ await websocket.send_text(json.dumps({"type": "error", "message": "File upload failed"}))
538
+ return
539
+
540
+ await manager.broadcast_to_room(
541
+ room,
542
+ {
543
+ "type": "file",
544
+ "username": username,
545
+ "fileName": file_name,
546
+ "fileType": file_type,
547
+ "fileSize": len(file_bytes),
548
+ "fileUrl": file_url,
549
+ "filePath": file_path,
550
+ "timestamp": utc_now(),
551
+ "expiresAt": expiry_time(),
552
+ "room": room,
553
+ },
554
+ )
555
+
556
+
557
  @app.get("/api/rooms")
558
  async def get_active_rooms():
 
559
  rooms = []
560
  for room_name, connections in manager.active_connections.items():
561
+ if connections:
562
+ rooms.append(
563
+ {
564
+ "name": room_name,
565
+ "user_count": len(connections),
566
+ "users": [conn["username"] for conn in connections],
567
+ }
568
+ )
569
  return {"rooms": rooms}
570
 
571
+
572
  @app.get("/api/rooms/{room}/users")
573
  async def get_room_users(room: str):
 
574
  users = manager.get_room_users(room)
575
  return {
576
  "room": room,
577
  "users": users,
578
+ "user_count": len(users),
579
  }
580
 
581
 
 
582
  if __name__ == "__main__":
583
+ port = int(os.getenv("PORT", 7860))
584
  uvicorn.run(app, host="0.0.0.0", port=port)
docs/images/README.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TriChat README Images
2
+
3
+ Generate the README images with the prompts in the main `README.md`, then save them here:
4
+
5
+ - `trichat-hero-banner.png`
6
+ - `office-problem-scene.png`
7
+ - `glowing-room-idea.png`
8
+ - `temp-room-flow.png`
9
+ - `auto-delete-scene.png`
10
+ - `friends-success-scene.png`
11
+
12
+ Recommended sizes:
13
+
14
+ - Hero banner: `1600x700`
15
+ - Story scenes: `1200x800`
16
+ - Flow/CTA images: `1200x700`
docs/images/auto-delete-scene.png ADDED

Git LFS Details

  • SHA256: 3ad9b0ad3b39ebdbaaa754e799619f401cf03d16e99ea403321af0f2b010c831
  • Pointer size: 132 Bytes
  • Size of remote file: 2.18 MB
docs/images/friends-success-scene.png ADDED

Git LFS Details

  • SHA256: 2f5500f55140326e97cab7866376cb15952181ae77e87d3ecf8f4e22c2d22c58
  • Pointer size: 132 Bytes
  • Size of remote file: 2.2 MB
docs/images/glowing-room-idea.png ADDED

Git LFS Details

  • SHA256: e91146c013120a3d1e1d6eeabd05c6d2f7d89c1687cf375faaa920671d233762
  • Pointer size: 132 Bytes
  • Size of remote file: 2.14 MB
docs/images/office-problem-scene.png ADDED

Git LFS Details

  • SHA256: e22ae235e9ef04222eeb44dcac886a5080dbd075af59bd3d2113ac3c2161aa8a
  • Pointer size: 132 Bytes
  • Size of remote file: 2.08 MB
docs/images/temp-room-flow.png ADDED

Git LFS Details

  • SHA256: 0827c42b83e8ddf596f432e66b1fec4477c779a135e61703f8f55b2348fab6df
  • Pointer size: 132 Bytes
  • Size of remote file: 2.14 MB
docs/images/trichat-hero-banner.png ADDED

Git LFS Details

  • SHA256: cd1c63beb8978e61331d0becb23ae02a12e74c5933ae78ad8e2cf402685dc784
  • Pointer size: 132 Bytes
  • Size of remote file: 2.01 MB
requirements.txt CHANGED
@@ -1,5 +1,5 @@
1
  fastapi==0.104.1
2
  uvicorn[standard]==0.24.0
3
  websockets==12.0
4
- python-multipart==0.0.6
5
  jinja2==3.1.2
 
 
1
  fastapi==0.104.1
2
  uvicorn[standard]==0.24.0
3
  websockets==12.0
 
4
  jinja2==3.1.2
5
+ httpx==0.27.2
static/main.js CHANGED
@@ -120,7 +120,13 @@ class TriChat {
120
  };
121
 
122
  this.ws.onmessage = (event) => {
123
- const message = JSON.parse(event.data);
 
 
 
 
 
 
124
 
125
  if (message.type === "user_list") {
126
  this.updateUserList(message.users || []);
@@ -293,9 +299,11 @@ class TriChat {
293
  <div class="message-content">${this.escapeHtml(message.text)}</div>
294
  `;
295
  } else if (message.type === "file") {
296
- const downloadUrl = `data:${message.fileType};base64,${message.fileData}`;
297
- const preview = message.fileType.startsWith("image/")
298
- ? `<div class="file-preview"><img src="${downloadUrl}" alt="${this.escapeHtml(message.fileName)}"></div>`
 
 
299
  : "";
300
  const fileIcon = this.getFileIcon(message.fileType);
301
 
@@ -307,11 +315,11 @@ class TriChat {
307
  <div class="message-content">
308
  <div class="file-summary">
309
  <i class="${fileIcon}"></i>
310
- <strong>${this.escapeHtml(message.fileName)}</strong>
311
  </div>
312
  <div class="file-size">${this.formatFileSize(message.fileSize)}</div>
313
  ${preview}
314
- <a href="${downloadUrl}" download="${this.escapeHtml(message.fileName)}" class="file-download">
315
  <i class="fas fa-download"></i>
316
  Download
317
  </a>
 
120
  };
121
 
122
  this.ws.onmessage = (event) => {
123
+ let message;
124
+ try {
125
+ message = JSON.parse(event.data);
126
+ } catch (error) {
127
+ console.error("Invalid WebSocket message:", error);
128
+ return;
129
+ }
130
 
131
  if (message.type === "user_list") {
132
  this.updateUserList(message.users || []);
 
299
  <div class="message-content">${this.escapeHtml(message.text)}</div>
300
  `;
301
  } else if (message.type === "file") {
302
+ const downloadUrl = message.fileUrl || "";
303
+ const safeDownloadUrl = this.escapeHtml(downloadUrl);
304
+ const safeFileName = this.escapeHtml(message.fileName);
305
+ const preview = downloadUrl && message.fileType.startsWith("image/")
306
+ ? `<div class="file-preview"><img src="${safeDownloadUrl}" alt="${safeFileName}"></div>`
307
  : "";
308
  const fileIcon = this.getFileIcon(message.fileType);
309
 
 
315
  <div class="message-content">
316
  <div class="file-summary">
317
  <i class="${fileIcon}"></i>
318
+ <strong>${safeFileName}</strong>
319
  </div>
320
  <div class="file-size">${this.formatFileSize(message.fileSize)}</div>
321
  ${preview}
322
+ <a href="${safeDownloadUrl}" download="${safeFileName}" class="file-download">
323
  <i class="fas fa-download"></i>
324
  Download
325
  </a>
supabase_schema.sql ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ create table if not exists public.messages (
2
+ id bigint generated by default as identity primary key,
3
+ room text not null,
4
+ username text not null,
5
+ message_type text not null check (message_type in ('text', 'file')),
6
+ text text,
7
+ file_url text,
8
+ file_path text,
9
+ file_name text,
10
+ file_type text,
11
+ file_size integer,
12
+ created_at timestamptz not null default now(),
13
+ expires_at timestamptz not null default (now() + interval '5 hours')
14
+ );
15
+
16
+ alter table public.messages
17
+ add column if not exists file_path text;
18
+
19
+ alter table public.messages
20
+ add column if not exists expires_at timestamptz;
21
+
22
+ update public.messages
23
+ set expires_at = created_at + interval '5 hours'
24
+ where expires_at is null;
25
+
26
+ alter table public.messages
27
+ alter column expires_at set default (now() + interval '5 hours');
28
+
29
+ alter table public.messages
30
+ alter column expires_at set not null;
31
+
32
+ create index if not exists messages_room_created_at_idx
33
+ on public.messages (room, created_at desc);
34
+
35
+ create index if not exists messages_expires_at_idx
36
+ on public.messages (expires_at);
37
+
38
+ alter table public.messages enable row level security;
39
+
40
+ drop policy if exists "Allow public read messages" on public.messages;
test.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta, timezone
2
+ from pathlib import Path
3
+ from uuid import uuid4
4
+ import os
5
+ import sys
6
+
7
+ import httpx
8
+
9
+
10
+ def load_env_file(path: str) -> None:
11
+ env_path = Path(path)
12
+ if not env_path.exists():
13
+ return
14
+
15
+ for raw_line in env_path.read_text(encoding="utf-8").splitlines():
16
+ line = raw_line.strip()
17
+ if not line or line.startswith("#") or "=" not in line:
18
+ continue
19
+
20
+ key, value = line.split("=", 1)
21
+ key = key.strip()
22
+ value = value.strip().strip('"').strip("'")
23
+ os.environ.setdefault(key, value)
24
+
25
+
26
+ def require_env(name: str) -> str:
27
+ value = os.getenv(name, "").strip()
28
+ if not value:
29
+ raise RuntimeError(f"Missing {name}. Add it to .env or .env.example.")
30
+ return value
31
+
32
+
33
+ def main() -> int:
34
+ load_env_file(".env")
35
+ load_env_file(".env.example")
36
+
37
+ supabase_url = require_env("SUPABASE_URL").rstrip("/")
38
+ supabase_key = require_env("SUPABASE_SERVICE_ROLE_KEY")
39
+
40
+ headers = {
41
+ "apikey": supabase_key,
42
+ "Authorization": f"Bearer {supabase_key}",
43
+ "Content-Type": "application/json",
44
+ }
45
+
46
+ test_room = f"test-{uuid4().hex}"
47
+ test_text = f"Supabase integration test {datetime.now(timezone.utc).isoformat()}"
48
+ payload = {
49
+ "room": test_room,
50
+ "username": "integration-test",
51
+ "message_type": "text",
52
+ "text": test_text,
53
+ "created_at": datetime.now(timezone.utc).isoformat(),
54
+ "expires_at": (datetime.now(timezone.utc) + timedelta(hours=5)).isoformat(),
55
+ }
56
+
57
+ with httpx.Client(timeout=20) as client:
58
+ insert_response = client.post(
59
+ f"{supabase_url}/rest/v1/messages",
60
+ headers={**headers, "Prefer": "return=representation"},
61
+ json=payload,
62
+ )
63
+ insert_response.raise_for_status()
64
+
65
+ inserted = insert_response.json()
66
+ if not inserted:
67
+ raise RuntimeError("Insert succeeded, but Supabase did not return a row.")
68
+
69
+ inserted_id = inserted[0]["id"]
70
+
71
+ select_response = client.get(
72
+ f"{supabase_url}/rest/v1/messages",
73
+ headers=headers,
74
+ params={
75
+ "select": "id,room,username,message_type,text,expires_at,file_path",
76
+ "id": f"eq.{inserted_id}",
77
+ },
78
+ )
79
+ select_response.raise_for_status()
80
+ rows = select_response.json()
81
+
82
+ if not rows or rows[0]["text"] != test_text:
83
+ raise RuntimeError("Inserted test row could not be read back correctly.")
84
+
85
+ if not rows[0].get("expires_at") or "file_path" not in rows[0]:
86
+ raise RuntimeError("Expiry columns are missing. Run supabase_schema.sql again.")
87
+
88
+ delete_response = client.delete(
89
+ f"{supabase_url}/rest/v1/messages",
90
+ headers=headers,
91
+ params={"id": f"eq.{inserted_id}"},
92
+ )
93
+ delete_response.raise_for_status()
94
+
95
+ print("Supabase database integration successful.")
96
+ print(f"Inserted, read, and deleted test row id: {inserted_id}")
97
+ return 0
98
+
99
+
100
+ if __name__ == "__main__":
101
+ try:
102
+ raise SystemExit(main())
103
+ except httpx.HTTPStatusError as exc:
104
+ response_body = exc.response.text[:500]
105
+ print(f"Supabase database integration failed: {exc}", file=sys.stderr)
106
+ print(f"Response body: {response_body}", file=sys.stderr)
107
+ raise SystemExit(1)
108
+ except Exception as exc:
109
+ print(f"Supabase database integration failed: {exc}", file=sys.stderr)
110
+ raise SystemExit(1)