lvwerra HF Staff commited on
Commit
d4fd753
·
verified ·
1 Parent(s): d9bfc7e

Upload folder using huggingface_hub

Browse files
Files changed (10) hide show
  1. Dockerfile +36 -0
  2. README.md +4 -6
  3. app/main.py +274 -0
  4. app/static/index.html +276 -0
  5. conduit.toml +13 -0
  6. nginx.conf +38 -0
  7. requirements.txt +4 -0
  8. start.sh +20 -0
  9. supervisord.conf +43 -0
  10. sync_loop.sh +12 -0
Dockerfile ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM docker.io/matrixconduit/matrix-conduit:latest AS conduit
2
+
3
+ FROM python:3.11-slim
4
+
5
+ # Install system deps
6
+ RUN apt-get update && \
7
+ apt-get install -y nginx supervisor curl && \
8
+ rm -rf /var/lib/apt/lists/*
9
+
10
+ # Copy conduit binary from official image
11
+ COPY --from=conduit /srv/conduit/conduit /usr/local/bin/conduit
12
+ RUN chmod +x /usr/local/bin/conduit
13
+
14
+ # Install Python deps
15
+ COPY requirements.txt /tmp/requirements.txt
16
+ RUN pip install --no-cache-dir -r /tmp/requirements.txt
17
+
18
+ # Config files
19
+ COPY conduit.toml /etc/conduit/conduit.toml
20
+ COPY nginx.conf /etc/nginx/nginx.conf
21
+ COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
22
+
23
+ # Scripts
24
+ COPY start.sh /start.sh
25
+ COPY sync_loop.sh /sync_loop.sh
26
+ RUN chmod +x /start.sh /sync_loop.sh
27
+
28
+ # Application
29
+ COPY app/ /app/
30
+
31
+ # Create directories
32
+ RUN mkdir -p /data/conduit /var/log/supervisor
33
+
34
+ EXPOSE 7860
35
+
36
+ CMD ["/start.sh"]
README.md CHANGED
@@ -1,10 +1,8 @@
1
  ---
2
  title: Messenger
3
- emoji: 🐢
4
- colorFrom: red
5
- colorTo: indigo
6
  sdk: docker
7
- pinned: false
8
  ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
  title: Messenger
3
+ emoji: "\U0001F4AC"
4
+ colorFrom: blue
5
+ colorTo: purple
6
  sdk: docker
7
+ app_port: 7860
8
  ---
 
 
app/main.py ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Messenger API — thin layer over Matrix/Conduit for zero-friction chat."""
2
+
3
+ import hashlib
4
+ import time
5
+ import urllib.parse
6
+ import uuid
7
+
8
+ import httpx
9
+ from fastapi import FastAPI, HTTPException
10
+ from fastapi.responses import FileResponse, HTMLResponse
11
+ from fastapi.staticfiles import StaticFiles
12
+ from pydantic import BaseModel
13
+
14
+ app = FastAPI(title="Messenger")
15
+
16
+ CONDUIT = "http://127.0.0.1:6167"
17
+ SERVER_NAME = "lvwerra-messenger.hf.space"
18
+ # Used to derive deterministic passwords for auto-created accounts
19
+ SECRET_SALT = "messenger-salt-v1"
20
+
21
+ # Cache: display_name -> {user_id, access_token}
22
+ _user_cache: dict[str, dict] = {}
23
+
24
+
25
+ # --- Models ---
26
+
27
+ class RegisterRequest(BaseModel):
28
+ name: str
29
+
30
+ class SendRequest(BaseModel):
31
+ text: str
32
+ name: str | None = None
33
+ token: str | None = None
34
+
35
+ class CreateRoomRequest(BaseModel):
36
+ name: str
37
+ token: str
38
+
39
+
40
+ # --- Matrix helpers ---
41
+
42
+ def _make_password(username: str) -> str:
43
+ return hashlib.sha256(f"{username}:{SECRET_SALT}".encode()).hexdigest()[:32]
44
+
45
+
46
+ async def _matrix_register(username: str, password: str) -> dict:
47
+ """Register a new Matrix user. Returns {user_id, access_token}."""
48
+ async with httpx.AsyncClient(timeout=10) as c:
49
+ # First call to get the session
50
+ resp = await c.post(f"{CONDUIT}/_matrix/client/v3/register", json={
51
+ "username": username,
52
+ "password": password,
53
+ "inhibit_login": False,
54
+ "initial_device_display_name": "Messenger",
55
+ })
56
+ data = resp.json()
57
+
58
+ # Conduit may require a UIAA step
59
+ if resp.status_code == 401 and "session" in data:
60
+ session = data["session"]
61
+ resp = await c.post(f"{CONDUIT}/_matrix/client/v3/register", json={
62
+ "username": username,
63
+ "password": password,
64
+ "auth": {"type": "m.login.dummy", "session": session},
65
+ "inhibit_login": False,
66
+ "initial_device_display_name": "Messenger",
67
+ })
68
+ data = resp.json()
69
+
70
+ if "access_token" in data:
71
+ return data
72
+ raise HTTPException(400, f"Registration failed: {data}")
73
+
74
+
75
+ async def _matrix_login(username: str, password: str) -> dict:
76
+ """Login to an existing Matrix account. Returns {user_id, access_token}."""
77
+ async with httpx.AsyncClient(timeout=10) as c:
78
+ resp = await c.post(f"{CONDUIT}/_matrix/client/v3/login", json={
79
+ "type": "m.login.password",
80
+ "identifier": {"type": "m.id.user", "user": username},
81
+ "password": password,
82
+ "initial_device_display_name": "Messenger",
83
+ })
84
+ data = resp.json()
85
+ if "access_token" in data:
86
+ return data
87
+ raise HTTPException(401, f"Login failed: {data}")
88
+
89
+
90
+ async def get_or_create_user(display_name: str) -> dict:
91
+ """Get or create a Matrix user from just a display name."""
92
+ if display_name in _user_cache:
93
+ return _user_cache[display_name]
94
+
95
+ username = display_name.lower().replace(" ", "_")
96
+ # Remove non-alphanumeric chars (Matrix usernames are restrictive)
97
+ username = "".join(c for c in username if c.isalnum() or c == "_")
98
+ if not username:
99
+ username = f"user_{uuid.uuid4().hex[:8]}"
100
+
101
+ password = _make_password(username)
102
+
103
+ # Try register first, fall back to login
104
+ try:
105
+ data = await _matrix_register(username, password)
106
+ except HTTPException:
107
+ data = await _matrix_login(username, password)
108
+
109
+ # Set display name
110
+ try:
111
+ async with httpx.AsyncClient(timeout=10) as c:
112
+ await c.put(
113
+ f"{CONDUIT}/_matrix/client/v3/profile/{data['user_id']}/displayname",
114
+ headers={"Authorization": f"Bearer {data['access_token']}"},
115
+ json={"displayname": display_name},
116
+ )
117
+ except Exception:
118
+ pass # Non-critical
119
+
120
+ result = {"user_id": data["user_id"], "access_token": data["access_token"]}
121
+ _user_cache[display_name] = result
122
+ return result
123
+
124
+
125
+ async def _resolve_room(room_alias: str, token: str) -> str:
126
+ """Resolve a room alias to a room_id."""
127
+ encoded = urllib.parse.quote(room_alias)
128
+ async with httpx.AsyncClient(timeout=10) as c:
129
+ resp = await c.get(
130
+ f"{CONDUIT}/_matrix/client/v3/directory/room/{encoded}",
131
+ headers={"Authorization": f"Bearer {token}"},
132
+ )
133
+ if resp.status_code == 200:
134
+ return resp.json()["room_id"]
135
+ return ""
136
+
137
+
138
+ async def _ensure_room(room_name: str, token: str) -> str:
139
+ """Get or create a room. Returns room_id."""
140
+ alias = f"#{room_name}:{SERVER_NAME}"
141
+ room_id = await _resolve_room(alias, token)
142
+ if room_id:
143
+ return room_id
144
+
145
+ # Create the room
146
+ async with httpx.AsyncClient(timeout=10) as c:
147
+ resp = await c.post(
148
+ f"{CONDUIT}/_matrix/client/v3/createRoom",
149
+ headers={"Authorization": f"Bearer {token}"},
150
+ json={
151
+ "room_alias_name": room_name,
152
+ "name": room_name,
153
+ "visibility": "public",
154
+ "preset": "public_chat",
155
+ },
156
+ )
157
+ data = resp.json()
158
+ if "room_id" in data:
159
+ return data["room_id"]
160
+ raise HTTPException(500, f"Failed to create room: {data}")
161
+
162
+
163
+ async def _join_room(room_id: str, token: str) -> None:
164
+ """Join a room (idempotent)."""
165
+ async with httpx.AsyncClient(timeout=10) as c:
166
+ await c.post(
167
+ f"{CONDUIT}/_matrix/client/v3/join/{urllib.parse.quote(room_id)}",
168
+ headers={"Authorization": f"Bearer {token}"},
169
+ )
170
+
171
+
172
+ async def _get_token(req: SendRequest) -> str:
173
+ """Extract token from request — either provided or auto-created from name."""
174
+ if req.token:
175
+ return req.token
176
+ if req.name:
177
+ user = await get_or_create_user(req.name)
178
+ return user["access_token"]
179
+ raise HTTPException(400, "Either 'name' or 'token' required")
180
+
181
+
182
+ # --- API endpoints ---
183
+
184
+ @app.post("/api/register")
185
+ async def register(req: RegisterRequest):
186
+ """Register or login with just a display name. Returns token."""
187
+ user = await get_or_create_user(req.name)
188
+ return {"user_id": user["user_id"], "token": user["access_token"]}
189
+
190
+
191
+ @app.post("/api/rooms")
192
+ async def create_room(req: CreateRoomRequest):
193
+ """Create a new room. Returns a shareable link."""
194
+ room_id = await _ensure_room(req.name, req.token)
195
+ return {
196
+ "room_id": room_id,
197
+ "link": f"https://{SERVER_NAME}/ch/{req.name}",
198
+ }
199
+
200
+
201
+ @app.post("/api/ch/{room}/join")
202
+ async def join_room(room: str, req: RegisterRequest):
203
+ """Join a room by name. Auto-creates room if it doesn't exist."""
204
+ user = await get_or_create_user(req.name)
205
+ room_id = await _ensure_room(room, user["access_token"])
206
+ await _join_room(room_id, user["access_token"])
207
+ return {"ok": True, "token": user["access_token"], "room_id": room_id}
208
+
209
+
210
+ @app.post("/api/ch/{room}/send")
211
+ async def send_message(room: str, req: SendRequest):
212
+ """Send a message to a room."""
213
+ token = await _get_token(req)
214
+ room_id = await _ensure_room(room, token)
215
+ await _join_room(room_id, token)
216
+
217
+ txn_id = uuid.uuid4().hex
218
+ async with httpx.AsyncClient(timeout=10) as c:
219
+ resp = await c.put(
220
+ f"{CONDUIT}/_matrix/client/v3/rooms/{room_id}/send/m.room.message/{txn_id}",
221
+ headers={"Authorization": f"Bearer {token}"},
222
+ json={"msgtype": "m.text", "body": req.text},
223
+ )
224
+ data = resp.json()
225
+ if "event_id" in data:
226
+ return {"event_id": data["event_id"]}
227
+ raise HTTPException(500, f"Failed to send: {data}")
228
+
229
+
230
+ @app.get("/api/ch/{room}/messages")
231
+ async def get_messages(room: str, token: str, limit: int = 50):
232
+ """Get recent messages from a room."""
233
+ alias = f"#{room}:{SERVER_NAME}"
234
+ room_id = await _resolve_room(alias, token)
235
+ if not room_id:
236
+ return {"messages": []}
237
+
238
+ async with httpx.AsyncClient(timeout=30) as c:
239
+ resp = await c.get(
240
+ f"{CONDUIT}/_matrix/client/v3/rooms/{room_id}/messages",
241
+ headers={"Authorization": f"Bearer {token}"},
242
+ params={"dir": "b", "limit": limit},
243
+ )
244
+ data = resp.json()
245
+
246
+ messages = []
247
+ for event in reversed(data.get("chunk", [])):
248
+ if event.get("type") != "m.room.message":
249
+ continue
250
+ content = event.get("content", {})
251
+ if content.get("msgtype") != "m.text":
252
+ continue
253
+ messages.append({
254
+ "id": event["event_id"],
255
+ "sender": event["sender"],
256
+ "sender_name": event.get("sender_name", event["sender"].split(":")[0].lstrip("@")),
257
+ "text": content["body"],
258
+ "timestamp": event.get("origin_server_ts", 0),
259
+ })
260
+ return {"messages": messages}
261
+
262
+
263
+ # --- Web UI ---
264
+
265
+ @app.get("/ch/{room}")
266
+ async def chat_page(room: str):
267
+ """Serve the chat UI for a room."""
268
+ return FileResponse("/app/static/index.html", media_type="text/html")
269
+
270
+
271
+ @app.get("/")
272
+ async def landing():
273
+ """Landing page."""
274
+ return FileResponse("/app/static/index.html", media_type="text/html")
app/static/index.html ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Messenger</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
11
+ background: #0f0f0f; color: #e0e0e0; height: 100dvh;
12
+ display: flex; flex-direction: column;
13
+ }
14
+ header {
15
+ padding: 12px 16px; background: #1a1a1a; border-bottom: 1px solid #2a2a2a;
16
+ font-weight: 600; font-size: 16px; flex-shrink: 0;
17
+ }
18
+ header span { color: #888; font-weight: 400; font-size: 13px; margin-left: 8px; }
19
+
20
+ /* Landing */
21
+ #landing {
22
+ display: flex; align-items: center; justify-content: center;
23
+ flex: 1; padding: 20px;
24
+ }
25
+ #landing .card {
26
+ background: #1a1a1a; border-radius: 12px; padding: 32px;
27
+ max-width: 400px; width: 100%; text-align: center;
28
+ }
29
+ #landing h1 { font-size: 24px; margin-bottom: 8px; }
30
+ #landing p { color: #888; margin-bottom: 24px; font-size: 14px; }
31
+ #landing input {
32
+ width: 100%; padding: 12px; border-radius: 8px; border: 1px solid #333;
33
+ background: #0f0f0f; color: #e0e0e0; font-size: 15px; margin-bottom: 12px;
34
+ outline: none;
35
+ }
36
+ #landing input:focus { border-color: #5b7bd5; }
37
+ #landing button {
38
+ width: 100%; padding: 12px; border-radius: 8px; border: none;
39
+ background: #5b7bd5; color: white; font-size: 15px; font-weight: 600;
40
+ cursor: pointer;
41
+ }
42
+ #landing button:hover { background: #4a6bc4; }
43
+
44
+ /* Join dialog */
45
+ #join-dialog {
46
+ display: flex; align-items: center; justify-content: center;
47
+ flex: 1; padding: 20px;
48
+ }
49
+ #join-dialog .card {
50
+ background: #1a1a1a; border-radius: 12px; padding: 32px;
51
+ max-width: 400px; width: 100%; text-align: center;
52
+ }
53
+ #join-dialog h2 { font-size: 20px; margin-bottom: 16px; }
54
+ #join-dialog input {
55
+ width: 100%; padding: 12px; border-radius: 8px; border: 1px solid #333;
56
+ background: #0f0f0f; color: #e0e0e0; font-size: 15px; margin-bottom: 12px;
57
+ outline: none;
58
+ }
59
+ #join-dialog input:focus { border-color: #5b7bd5; }
60
+ #join-dialog button {
61
+ width: 100%; padding: 12px; border-radius: 8px; border: none;
62
+ background: #5b7bd5; color: white; font-size: 15px; font-weight: 600;
63
+ cursor: pointer;
64
+ }
65
+ #join-dialog button:hover { background: #4a6bc4; }
66
+
67
+ /* Chat */
68
+ #chat { display: none; flex-direction: column; flex: 1; min-height: 0; }
69
+ #messages {
70
+ flex: 1; overflow-y: auto; padding: 16px; display: flex;
71
+ flex-direction: column; gap: 4px;
72
+ }
73
+ .msg {
74
+ padding: 6px 0; line-height: 1.4;
75
+ }
76
+ .msg .name { font-weight: 600; margin-right: 8px; }
77
+ .msg .time { color: #555; font-size: 11px; margin-left: 6px; }
78
+ .msg.self .name { color: #5b7bd5; }
79
+ .msg.system { color: #666; font-style: italic; font-size: 13px; }
80
+ #input-bar {
81
+ padding: 12px 16px; background: #1a1a1a; border-top: 1px solid #2a2a2a;
82
+ display: flex; gap: 8px; flex-shrink: 0;
83
+ }
84
+ #msg-input {
85
+ flex: 1; padding: 10px 14px; border-radius: 8px; border: 1px solid #333;
86
+ background: #0f0f0f; color: #e0e0e0; font-size: 15px; outline: none;
87
+ }
88
+ #msg-input:focus { border-color: #5b7bd5; }
89
+ #send-btn {
90
+ padding: 10px 20px; border-radius: 8px; border: none;
91
+ background: #5b7bd5; color: white; font-size: 15px; font-weight: 600;
92
+ cursor: pointer;
93
+ }
94
+ #send-btn:hover { background: #4a6bc4; }
95
+ .hidden { display: none !important; }
96
+ </style>
97
+ </head>
98
+ <body>
99
+
100
+ <header id="header">
101
+ Messenger <span id="room-label"></span>
102
+ </header>
103
+
104
+ <!-- Landing: create or go to a room -->
105
+ <div id="landing" class="hidden">
106
+ <div class="card">
107
+ <h1>Messenger</h1>
108
+ <p>Create a chat room and share the link</p>
109
+ <input type="text" id="room-name-input" placeholder="Room name (e.g. my-project)">
110
+ <button onclick="goToRoom()">Create Room</button>
111
+ </div>
112
+ </div>
113
+
114
+ <!-- Join dialog: pick a display name -->
115
+ <div id="join-dialog" class="hidden">
116
+ <div class="card">
117
+ <h2 id="join-title">Join room</h2>
118
+ <input type="text" id="display-name" placeholder="Your name" autofocus>
119
+ <button onclick="joinRoom()">Join</button>
120
+ </div>
121
+ </div>
122
+
123
+ <!-- Chat view -->
124
+ <div id="chat">
125
+ <div id="messages"></div>
126
+ <div id="input-bar">
127
+ <input type="text" id="msg-input" placeholder="Type a message..." autofocus>
128
+ <button id="send-btn" onclick="sendMessage()">Send</button>
129
+ </div>
130
+ </div>
131
+
132
+ <script>
133
+ const path = window.location.pathname;
134
+ const roomMatch = path.match(/^\/ch\/([^/]+)/);
135
+ const roomName = roomMatch ? roomMatch[1] : null;
136
+
137
+ let token = null;
138
+ let userId = null;
139
+ let lastMessageId = null;
140
+ let pollTimer = null;
141
+
142
+ // Determine which view to show
143
+ if (!roomName) {
144
+ document.getElementById('landing').classList.remove('hidden');
145
+ document.getElementById('chat').style.display = 'none';
146
+ } else {
147
+ document.getElementById('room-label').textContent = '#' + roomName;
148
+
149
+ // Check for saved session
150
+ const saved = localStorage.getItem('messenger_' + roomName);
151
+ if (saved) {
152
+ const s = JSON.parse(saved);
153
+ token = s.token;
154
+ userId = s.userId;
155
+ startChat();
156
+ } else {
157
+ document.getElementById('join-dialog').classList.remove('hidden');
158
+ document.getElementById('join-title').textContent = 'Join #' + roomName;
159
+ document.getElementById('chat').style.display = 'none';
160
+ }
161
+ }
162
+
163
+ function goToRoom() {
164
+ const name = document.getElementById('room-name-input').value.trim()
165
+ .toLowerCase().replace(/[^a-z0-9_-]/g, '-');
166
+ if (name) window.location.href = '/ch/' + name;
167
+ }
168
+
169
+ async function joinRoom() {
170
+ const name = document.getElementById('display-name').value.trim();
171
+ if (!name) return;
172
+
173
+ try {
174
+ const resp = await fetch('/api/ch/' + roomName + '/join', {
175
+ method: 'POST',
176
+ headers: {'Content-Type': 'application/json'},
177
+ body: JSON.stringify({name}),
178
+ });
179
+ const data = await resp.json();
180
+ if (!resp.ok) throw new Error(data.detail || 'Join failed');
181
+
182
+ token = data.token;
183
+ userId = name;
184
+ localStorage.setItem('messenger_' + roomName, JSON.stringify({token, userId}));
185
+ startChat();
186
+ } catch (e) {
187
+ alert('Failed to join: ' + e.message);
188
+ }
189
+ }
190
+
191
+ function startChat() {
192
+ document.getElementById('landing').classList.add('hidden');
193
+ document.getElementById('join-dialog').classList.add('hidden');
194
+ document.getElementById('chat').style.display = 'flex';
195
+ document.getElementById('msg-input').focus();
196
+ loadMessages();
197
+ pollTimer = setInterval(loadMessages, 2000);
198
+ }
199
+
200
+ async function loadMessages() {
201
+ try {
202
+ const resp = await fetch(`/api/ch/${roomName}/messages?token=${encodeURIComponent(token)}&limit=100`);
203
+ const data = await resp.json();
204
+ renderMessages(data.messages || []);
205
+ } catch (e) {
206
+ console.error('Failed to load messages:', e);
207
+ }
208
+ }
209
+
210
+ function renderMessages(msgs) {
211
+ const container = document.getElementById('messages');
212
+ const wasAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 50;
213
+
214
+ // Check if new messages arrived
215
+ const lastId = msgs.length ? msgs[msgs.length - 1].id : null;
216
+ if (lastId === lastMessageId) return;
217
+ lastMessageId = lastId;
218
+
219
+ container.innerHTML = '';
220
+ for (const msg of msgs) {
221
+ const div = document.createElement('div');
222
+ const senderName = msg.sender_name || msg.sender.split(':')[0].replace('@', '');
223
+ const isSelf = senderName.toLowerCase() === userId?.toLowerCase() ||
224
+ msg.sender_name === userId;
225
+ div.className = 'msg' + (isSelf ? ' self' : '');
226
+
227
+ const time = new Date(msg.timestamp).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
228
+ div.innerHTML = `<span class="name">${esc(senderName)}</span>${esc(msg.text)}<span class="time">${time}</span>`;
229
+ container.appendChild(div);
230
+ }
231
+
232
+ if (wasAtBottom) container.scrollTop = container.scrollHeight;
233
+ }
234
+
235
+ async function sendMessage() {
236
+ const input = document.getElementById('msg-input');
237
+ const text = input.value.trim();
238
+ if (!text || !token) return;
239
+
240
+ input.value = '';
241
+ try {
242
+ await fetch(`/api/ch/${roomName}/send`, {
243
+ method: 'POST',
244
+ headers: {'Content-Type': 'application/json'},
245
+ body: JSON.stringify({token, text}),
246
+ });
247
+ loadMessages();
248
+ } catch (e) {
249
+ console.error('Send failed:', e);
250
+ }
251
+ }
252
+
253
+ function esc(s) {
254
+ const d = document.createElement('div');
255
+ d.textContent = s;
256
+ return d.innerHTML;
257
+ }
258
+
259
+ // Enter to send
260
+ document.addEventListener('keydown', (e) => {
261
+ if (e.key === 'Enter' && !e.shiftKey) {
262
+ if (document.activeElement === document.getElementById('msg-input')) {
263
+ e.preventDefault();
264
+ sendMessage();
265
+ } else if (document.activeElement === document.getElementById('display-name')) {
266
+ e.preventDefault();
267
+ joinRoom();
268
+ } else if (document.activeElement === document.getElementById('room-name-input')) {
269
+ e.preventDefault();
270
+ goToRoom();
271
+ }
272
+ }
273
+ });
274
+ </script>
275
+ </body>
276
+ </html>
conduit.toml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [global]
2
+ server_name = "lvwerra-messenger.hf.space"
3
+ database_backend = "sqlite"
4
+ database_path = "/data/conduit/"
5
+ port = 6167
6
+ max_request_size = 20_000_000
7
+ allow_registration = true
8
+ allow_federation = false
9
+ allow_check_for_updates = false
10
+ trusted_servers = ["matrix.org"]
11
+
12
+ [global.well_known]
13
+ client = "https://lvwerra-messenger.hf.space"
nginx.conf ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ worker_processes 1;
2
+ events { worker_connections 1024; }
3
+
4
+ http {
5
+ include /etc/nginx/mime.types;
6
+ default_type application/octet-stream;
7
+ sendfile on;
8
+ keepalive_timeout 65;
9
+
10
+ server {
11
+ listen 7860;
12
+
13
+ # Matrix client-server API → Conduit
14
+ location /_matrix/ {
15
+ proxy_pass http://127.0.0.1:6167;
16
+ proxy_set_header Host $host;
17
+ proxy_set_header X-Real-IP $remote_addr;
18
+ proxy_buffering off;
19
+ proxy_read_timeout 600s;
20
+ client_max_body_size 20M;
21
+ }
22
+
23
+ # .well-known for Matrix client discovery
24
+ location /.well-known/matrix/ {
25
+ proxy_pass http://127.0.0.1:6167;
26
+ proxy_set_header Host $host;
27
+ }
28
+
29
+ # Everything else → FastAPI
30
+ location / {
31
+ proxy_pass http://127.0.0.1:8000;
32
+ proxy_set_header Host $host;
33
+ proxy_set_header X-Real-IP $remote_addr;
34
+ proxy_buffering off;
35
+ proxy_read_timeout 120s;
36
+ }
37
+ }
38
+ }
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.30.0
3
+ httpx==0.27.0
4
+ huggingface_hub[cli,hf_xet]>=0.30.0
start.sh ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ echo "=== Messenger starting ==="
5
+
6
+ # Create data directories
7
+ mkdir -p /data/conduit
8
+
9
+ # Sync database from HF bucket (if exists)
10
+ if [ -n "$HF_TOKEN" ]; then
11
+ echo "Syncing database from bucket..."
12
+ huggingface-cli download \
13
+ lvwerra/messenger-storage \
14
+ --repo-type dataset \
15
+ --local-dir /data/conduit/ \
16
+ 2>/dev/null || echo "No existing data in bucket (first run)"
17
+ fi
18
+
19
+ echo "Starting services..."
20
+ exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
supervisord.conf ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [supervisord]
2
+ nodaemon=true
3
+ logfile=/var/log/supervisor/supervisord.log
4
+ pidfile=/var/run/supervisord.pid
5
+ user=root
6
+
7
+ [program:conduit]
8
+ command=/usr/local/bin/conduit
9
+ environment=CONDUIT_CONFIG="/etc/conduit/conduit.toml"
10
+ autostart=true
11
+ autorestart=true
12
+ stdout_logfile=/dev/fd/1
13
+ stdout_logfile_maxbytes=0
14
+ stderr_logfile=/dev/fd/2
15
+ stderr_logfile_maxbytes=0
16
+
17
+ [program:fastapi]
18
+ command=uvicorn app.main:app --host 0.0.0.0 --port 8000
19
+ directory=/app
20
+ autostart=true
21
+ autorestart=true
22
+ stdout_logfile=/dev/fd/1
23
+ stdout_logfile_maxbytes=0
24
+ stderr_logfile=/dev/fd/2
25
+ stderr_logfile_maxbytes=0
26
+
27
+ [program:nginx]
28
+ command=nginx -g "daemon off;"
29
+ autostart=true
30
+ autorestart=true
31
+ stdout_logfile=/dev/fd/1
32
+ stdout_logfile_maxbytes=0
33
+ stderr_logfile=/dev/fd/2
34
+ stderr_logfile_maxbytes=0
35
+
36
+ [program:sync]
37
+ command=/sync_loop.sh
38
+ autostart=true
39
+ autorestart=true
40
+ stdout_logfile=/dev/fd/1
41
+ stdout_logfile_maxbytes=0
42
+ stderr_logfile=/dev/fd/2
43
+ stderr_logfile_maxbytes=0
sync_loop.sh ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Periodic sync of Conduit database to HF bucket
3
+ while true; do
4
+ sleep 60
5
+ if [ -n "$HF_TOKEN" ] && [ -d /data/conduit ]; then
6
+ huggingface-cli upload \
7
+ lvwerra/messenger-storage \
8
+ /data/conduit/ \
9
+ --repo-type dataset \
10
+ 2>/dev/null || echo "Sync failed, will retry"
11
+ fi
12
+ done