Commit ·
241535f
1
Parent(s): 91db40f
updated
Browse files- .env.example +21 -0
- .gitignore +6 -0
- README.md +326 -1
- app.py +495 -167
- docs/images/README.md +16 -0
- docs/images/auto-delete-scene.png +3 -0
- docs/images/friends-success-scene.png +3 -0
- docs/images/glowing-room-idea.png +3 -0
- docs/images/office-problem-scene.png +3 -0
- docs/images/temp-room-flow.png +3 -0
- docs/images/trichat-hero-banner.png +3 -0
- requirements.txt +1 -1
- static/main.js +14 -6
- supabase_schema.sql +40 -0
- test.py +110 -0
.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 |
+

|
| 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 |
+

|
| 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 |
+

|
| 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 |
+

|
| 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
|
| 2 |
-
from
|
| 3 |
-
from
|
| 4 |
-
from
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
app.mount("/static", StaticFiles(directory="static"), name="static")
|
| 17 |
|
| 18 |
-
# Setup templates
|
| 19 |
-
templates = Jinja2Templates(directory="templates")
|
| 20 |
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
class ConnectionManager:
|
| 23 |
def __init__(self):
|
| 24 |
-
# Store active connections by room
|
| 25 |
self.active_connections: Dict[str, List[Dict]] = {}
|
| 26 |
-
|
| 27 |
-
self.
|
| 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.
|
| 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 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
await websocket.send_text(json.dumps(message))
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
| 57 |
await self.broadcast_user_list(room)
|
| 58 |
-
|
| 59 |
-
def
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 125 |
-
return HTMLResponse(content=html_content)
|
| 126 |
except FileNotFoundError:
|
| 127 |
return HTMLResponse(
|
| 128 |
-
content="<h1>Error: templates/index.html not found</h1>
|
| 129 |
-
status_code=404
|
| 130 |
)
|
| 131 |
|
|
|
|
| 132 |
@app.websocket("/ws/{room}")
|
| 133 |
async def websocket_endpoint(websocket: WebSocket, room: str, username: str):
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
if not username
|
| 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 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
|
|
|
| 158 |
continue
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
if
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 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 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 235 |
-
rooms.append(
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 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))
|
| 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
|
docs/images/friends-success-scene.png
ADDED
|
Git LFS Details
|
docs/images/glowing-room-idea.png
ADDED
|
Git LFS Details
|
docs/images/office-problem-scene.png
ADDED
|
Git LFS Details
|
docs/images/temp-room-flow.png
ADDED
|
Git LFS Details
|
docs/images/trichat-hero-banner.png
ADDED
|
Git LFS Details
|
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 297 |
-
const
|
| 298 |
-
|
|
|
|
|
|
|
| 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>${
|
| 311 |
</div>
|
| 312 |
<div class="file-size">${this.formatFileSize(message.fileSize)}</div>
|
| 313 |
${preview}
|
| 314 |
-
<a href="${
|
| 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)
|