Starchik1 commited on
Commit
b65d807
·
verified ·
1 Parent(s): 13b0ec9

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +622 -0
main.py CHANGED
@@ -0,0 +1,622 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ flask_messenger_single_file.py
3
+ Advanced single-file Flask messenger optimized for mobile (PWA-ready).
4
+
5
+ Features:
6
+ - Single-file Flask + Flask-SocketIO app
7
+ - SQLite message store
8
+ - Real-time chat via WebSocket (Socket.IO)
9
+ - File uploads with chunked upload support for large files
10
+ - Direct blob upload endpoint for audio messages
11
+ - Typing indicator, simple settings modal, avatars (DiceBear)
12
+ - Mobile-first responsive UI embedded in the file
13
+
14
+ Dependencies:
15
+ pip install flask flask-socketio
16
+ Optional (better concurrency):
17
+ pip install eventlet
18
+ Run:
19
+ python flask_messenger_single_file.py
20
+ Notes for Android (Pydroid3):
21
+ - If eventlet is unavailable, the app will use the 'threading' async mode.
22
+ - Open in mobile browser: http://<device-ip>:5000
23
+ """
24
+
25
+ import os
26
+ import uuid
27
+ import sqlite3
28
+ import pathlib
29
+ from datetime import datetime
30
+ from flask import (
31
+ Flask,
32
+ request,
33
+ url_for,
34
+ send_from_directory,
35
+ jsonify,
36
+ render_template_string,
37
+ )
38
+ from flask_socketio import SocketIO, emit, join_room
39
+
40
+ # Try to import eventlet for better Socket.IO behavior; fallback allowed
41
+ try:
42
+ import eventlet # optional
43
+ _HAS_EVENTLET = True
44
+ except Exception:
45
+ _HAS_EVENTLET = False
46
+
47
+ # Configuration
48
+ BASE_DIR = pathlib.Path(__file__).parent.resolve()
49
+ UPLOAD_DIR = BASE_DIR / "uploads"
50
+ DB_PATH = BASE_DIR / "chat.db"
51
+ STATIC_DIR = BASE_DIR / "static"
52
+ CHUNK_TEMP = BASE_DIR / "chunk_temp"
53
+
54
+ os.makedirs(UPLOAD_DIR, exist_ok=True)
55
+ os.makedirs(STATIC_DIR, exist_ok=True)
56
+ CHUNK_TEMP.mkdir(exist_ok=True)
57
+
58
+ app = Flask(__name__)
59
+ app.config["SECRET_KEY"] = os.environ.get("FLASK_SECRET", "dev_secret_change_me")
60
+ app.config["UPLOAD_FOLDER"] = str(UPLOAD_DIR)
61
+
62
+ # Choose async mode
63
+ _async_mode = "eventlet" if _HAS_EVENTLET else "threading"
64
+ socketio = SocketIO(app, cors_allowed_origins="*", async_mode=_async_mode)
65
+
66
+ # --- Database helpers ---
67
+ def init_db():
68
+ conn = sqlite3.connect(DB_PATH)
69
+ cur = conn.cursor()
70
+ cur.execute(
71
+ """
72
+ CREATE TABLE IF NOT EXISTS messages (
73
+ id TEXT PRIMARY KEY,
74
+ username TEXT,
75
+ avatar TEXT,
76
+ room TEXT,
77
+ text TEXT,
78
+ file_path TEXT,
79
+ file_name TEXT,
80
+ mime TEXT,
81
+ timestamp TEXT,
82
+ status TEXT
83
+ )
84
+ """
85
+ )
86
+ conn.commit()
87
+ conn.close()
88
+
89
+
90
+ def save_message(msg: dict):
91
+ conn = sqlite3.connect(DB_PATH)
92
+ cur = conn.cursor()
93
+ cur.execute(
94
+ """INSERT INTO messages (id, username, avatar, room, text, file_path, file_name, mime, timestamp, status)
95
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
96
+ (
97
+ msg["id"],
98
+ msg.get("username"),
99
+ msg.get("avatar"),
100
+ msg.get("room"),
101
+ msg.get("text"),
102
+ msg.get("file_path"),
103
+ msg.get("file_name"),
104
+ msg.get("mime"),
105
+ msg.get("timestamp"),
106
+ msg.get("status", "sent"),
107
+ ),
108
+ )
109
+ conn.commit()
110
+ conn.close()
111
+
112
+
113
+ def get_recent_messages(room: str, limit: int = 200):
114
+ conn = sqlite3.connect(DB_PATH)
115
+ cur = conn.cursor()
116
+ cur.execute(
117
+ "SELECT id, username, avatar, text, file_path, file_name, mime, timestamp, status FROM messages WHERE room=? ORDER BY timestamp ASC LIMIT ?",
118
+ (room, limit),
119
+ )
120
+ rows = cur.fetchall()
121
+ conn.close()
122
+ out = []
123
+ for r in rows:
124
+ out.append(
125
+ {
126
+ "id": r[0],
127
+ "username": r[1],
128
+ "avatar": r[2],
129
+ "text": r[3],
130
+ "file_path": r[4],
131
+ "file_name": r[5],
132
+ "mime": r[6],
133
+ "timestamp": r[7],
134
+ "status": r[8],
135
+ }
136
+ )
137
+ return out
138
+
139
+
140
+ init_db()
141
+
142
+ # --- UI Template (embedded) ---
143
+ TEMPLATE = r"""
144
+ <!doctype html>
145
+ <html lang="ru">
146
+ <head>
147
+ <meta charset="utf-8" />
148
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
149
+ <title>Mobile Messenger</title>
150
+ <link rel="manifest" href="/manifest.json">
151
+ <meta name="theme-color" content="#0d6efd" />
152
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
153
+ <style>
154
+ :root{--accent:#0d6efd}
155
+ body{background:#f6f7fb;height:100vh;display:flex;flex-direction:column;margin:0}
156
+ .app{max-width:900px;margin:0 auto;flex:1;display:flex;flex-direction:column;min-height:0}
157
+ header{background:linear-gradient(90deg,var(--accent),#0056b3);color:#fff;padding:.9rem}
158
+ .chat-window{flex:1;overflow:auto;padding:1rem;display:flex;flex-direction:column;gap:.6rem;min-height:0}
159
+ .msg{max-width:78%;padding:.6rem .8rem;border-radius:12px;box-shadow:0 1px 0 rgba(0,0,0,0.05);word-break:break-word}
160
+ .from-me{align-self:flex-end;background:#dff3ff}
161
+ .from-other{align-self:flex-start;background:#fff}
162
+ .meta{font-size:.75rem;color:#666;margin-bottom:.25rem;display:flex;align-items:center;gap:.5rem}
163
+ .input-area{padding:.6rem;background:#fff;border-top:1px solid #eee}
164
+ .attachments{display:flex;gap:.5rem;align-items:center}
165
+ .file-preview{max-width:160px;border-radius:8px;overflow:hidden}
166
+ .typing{font-style:italic;color:#666;font-size:.85rem}
167
+ .avatar{width:36px;height:36px;border-radius:50%;object-fit:cover}
168
+ .modal{display:none;position:fixed;top:0;left:0;right:0;bottom:0;align-items:center;justify-content:center;background:rgba(0,0,0,0.4)}
169
+ .modal.show{display:flex}
170
+ .modal-dialog{max-width:420px;width:100%}
171
+ @media (max-width:480px){ .msg{max-width:92%} header{padding:.7rem} }
172
+ </style>
173
+ </head>
174
+ <body>
175
+ <div class="app shadow-sm d-flex flex-column">
176
+ <header class="d-flex align-items-center justify-content-between">
177
+ <div class="d-flex align-items-center gap-2">
178
+ <img id="roomAvatar" src="" class="avatar" alt="">
179
+ <div>
180
+ <div id="roomName">Mobile Chat</div>
181
+ <div style="font-size:.75rem;opacity:.9">Fast, private, PWA-ready</div>
182
+ </div>
183
+ </div>
184
+ <div>
185
+ <button id="btnSettings" class="btn btn-sm btn-light">Настройки</button>
186
+ </div>
187
+ </header>
188
+
189
+ <div id="chat" class="chat-window" role="log" aria-live="polite"></div>
190
+
191
+ <div class="input-area">
192
+ <div id="typing" class="typing"></div>
193
+ <form id="composer" class="d-flex gap-2 align-items-center">
194
+ <div class="attachments d-flex align-items-center">
195
+ <label class="btn btn-outline-secondary btn-sm mb-0" title="Прикрепить файл">
196
+ 📎<input id="fileInput" type="file" hidden>
197
+ </label>
198
+ <button id="recordBtn" class="btn btn-outline-danger btn-sm mb-0" type="button" title="Записать голос">🔴</button>
199
+ </div>
200
+ <input id="message" autocomplete="off" placeholder="Напишите сообщение..." class="form-control form-control-sm" />
201
+ <button class="btn btn-primary btn-sm" type="submit">Отправить</button>
202
+ </form>
203
+ </div>
204
+ </div>
205
+
206
+ <!-- Settings Modal -->
207
+ <div class="modal" id="settingsModal" aria-hidden="true">
208
+ <div class="modal-dialog">
209
+ <div class="modal-content bg-white rounded p-3">
210
+ <div class="d-flex justify-content-between align-items-center mb-2">
211
+ <h5 class="m-0">Настройки</h5>
212
+ <button id="closeSettings" class="btn btn-sm btn-outline-secondary">✖</button>
213
+ </div>
214
+ <div class="mb-2">
215
+ <label class="form-label">Никнейм</label>
216
+ <input id="setUsername" class="form-control" />
217
+ </div>
218
+ <div class="mb-2">
219
+ <label class="form-label">URL аватарки (оставьте пустым для генерации)</label>
220
+ <input id="setAvatar" class="form-control" placeholder="https://..." />
221
+ </div>
222
+ <div class="d-flex justify-content-end">
223
+ <button id="saveSettings" class="btn btn-primary">Сохранить</button>
224
+ </div>
225
+ </div>
226
+ </div>
227
+ </div>
228
+
229
+ <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
230
+ <script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
231
+ <script>
232
+ const socket = io();
233
+ let username = localStorage.getItem('mm_username') || '';
234
+ let avatar = localStorage.getItem('mm_avatar') || '';
235
+ const room = 'global';
236
+
237
+ function nano(){return Math.random().toString(36).slice(2,10);}
238
+ if(!username){ username = 'User-'+ nano(); }
239
+ if(!avatar){ avatar = 'https://api.dicebear.com/6.x/thumbs/svg?seed='+encodeURIComponent(username); }
240
+ localStorage.setItem('mm_username', username);
241
+ localStorage.setItem('mm_avatar', avatar);
242
+
243
+ document.getElementById('roomAvatar').src = avatar;
244
+ document.getElementById('roomName').innerText = room;
245
+
246
+ // Settings modal handlers
247
+ const btnSettings = document.getElementById('btnSettings');
248
+ const settingsModal = document.getElementById('settingsModal');
249
+ const closeSettings = document.getElementById('closeSettings');
250
+ const saveSettings = document.getElementById('saveSettings');
251
+ const setUsername = document.getElementById('setUsername');
252
+ const setAvatar = document.getElementById('setAvatar');
253
+
254
+ btnSettings.addEventListener('click', ()=>{
255
+ setUsername.value = localStorage.getItem('mm_username') || '';
256
+ setAvatar.value = localStorage.getItem('mm_avatar') || '';
257
+ settingsModal.classList.add('show');
258
+ settingsModal.setAttribute('aria-hidden','false');
259
+ });
260
+ closeSettings.addEventListener('click', ()=>{ settingsModal.classList.remove('show'); settingsModal.setAttribute('aria-hidden','true'); });
261
+ saveSettings.addEventListener('click', ()=>{
262
+ const nu = setUsername.value.trim() || ('User-'+nano());
263
+ const na = setAvatar.value.trim() || ('https://api.dicebear.com/6.x/thumbs/svg?seed='+encodeURIComponent(nu));
264
+ username = nu; avatar = na;
265
+ localStorage.setItem('mm_username', username);
266
+ localStorage.setItem('mm_avatar', avatar);
267
+ document.getElementById('roomAvatar').src = avatar;
268
+ settingsModal.classList.remove('show');
269
+ settingsModal.setAttribute('aria-hidden','true');
270
+ });
271
+
272
+ // load recent messages
273
+ async function loadRecent(){
274
+ try{
275
+ const r = await axios.get('/history?room='+encodeURIComponent(room));
276
+ const list = r.data||[];
277
+ const chat = document.getElementById('chat');
278
+ chat.innerHTML='';
279
+ list.forEach(addMessageToDom);
280
+ chat.scrollTop = chat.scrollHeight;
281
+ }catch(e){console.error(e)}
282
+ }
283
+
284
+ function safeAvatarFor(msg){
285
+ return msg.avatar || ('https://api.dicebear.com/6.x/thumbs/svg?seed='+encodeURIComponent(msg.username||'anon'));
286
+ }
287
+
288
+ function addMessageToDom(m){
289
+ const chat = document.getElementById('chat');
290
+ const el = document.createElement('div');
291
+ const fromMe = m.username === username;
292
+ el.className = 'msg '+(fromMe? 'from-me':'from-other');
293
+ const meta = document.createElement('div'); meta.className='meta';
294
+ const avatarSrc = safeAvatarFor(m);
295
+ meta.innerHTML = `<img src="${avatarSrc}" class="avatar"> <strong>${m.username||'Anonymous'}</strong> · <span>${new Date(m.timestamp).toLocaleString()}</span>`;
296
+ const body = document.createElement('div');
297
+ if(m.text) {
298
+ const p = document.createElement('div');
299
+ p.textContent = m.text;
300
+ body.appendChild(p);
301
+ }
302
+ if(m.file_path){
303
+ if(m.mime && m.mime.startsWith('image')){
304
+ const a = document.createElement('a'); a.href = m.file_path; a.target='_blank'; a.className='d-block mt-2';
305
+ const img = document.createElement('img'); img.src = m.file_path; img.className='file-preview img-fluid';
306
+ a.appendChild(img);
307
+ body.appendChild(a);
308
+ } else if(m.mime && m.mime.startsWith('audio')){
309
+ const audio = document.createElement('audio'); audio.controls = true; audio.src = m.file_path; audio.className='d-block mt-2';
310
+ body.appendChild(audio);
311
+ } else {
312
+ const a = document.createElement('a'); a.href = m.file_path; a.target='_blank'; a.className='d-block mt-2';
313
+ a.textContent = '📎 ' + (m.file_name || 'Файл');
314
+ body.appendChild(a);
315
+ }
316
+ }
317
+ el.appendChild(meta); el.appendChild(body);
318
+ chat.appendChild(el);
319
+ chat.scrollTop = chat.scrollHeight;
320
+ }
321
+
322
+ socket.on('connect', ()=>{
323
+ socket.emit('join', {room, username, avatar});
324
+ });
325
+
326
+ socket.on('message', (m)=>{ addMessageToDom(m); });
327
+ socket.on('typing', (data)=>{ document.getElementById('typing').innerText = data.username ? data.username + ' печатает...' : ''; });
328
+
329
+ // composer
330
+ const composer = document.getElementById('composer');
331
+ const messageInput = document.getElementById('message');
332
+ const fileInput = document.getElementById('fileInput');
333
+ let typingTimer = null;
334
+ messageInput && messageInput.addEventListener('input', ()=>{
335
+ socket.emit('typing', {room, username});
336
+ clearTimeout(typingTimer);
337
+ typingTimer = setTimeout(()=> socket.emit('typing', {room, username: ''}), 1200);
338
+ });
339
+
340
+ composer.addEventListener('submit', async (e)=>{
341
+ e.preventDefault();
342
+ const text = messageInput.value.trim();
343
+ if(fileInput.files.length){
344
+ const file = fileInput.files[0];
345
+ // Use chunked upload for large files
346
+ const fileUrl = await uploadFileChunked(file);
347
+ socket.emit('post_file_message', {username, avatar, room, text:'', file_path: fileUrl, file_name: file.name, mime: file.type});
348
+ fileInput.value='';
349
+ } else if(text){
350
+ socket.emit('post_message', {username, avatar, room, text});
351
+ messageInput.value='';
352
+ }
353
+ });
354
+
355
+ // chunked upload (returns file URL)
356
+ async function uploadFileChunked(file){
357
+ const chunkSize = 1024*256; // 256 KB
358
+ const total = file.size;
359
+ let offset = 0; let idx = 0;
360
+ const fileId = 'f_'+Date.now()+'_'+Math.random().toString(36).slice(2,8);
361
+ while(offset < total){
362
+ const chunk = file.slice(offset, offset+chunkSize);
363
+ const form = new FormData();
364
+ form.append('chunk', chunk);
365
+ form.append('file_id', fileId);
366
+ form.append('index', idx);
367
+ form.append('total', Math.ceil(total/chunkSize));
368
+ form.append('file_name', file.name);
369
+ await axios.post('/upload_chunk', form, { headers: {'Content-Type': 'multipart/form-data'} });
370
+ offset += chunkSize; idx += 1;
371
+ }
372
+ const r = await axios.post('/upload_complete', {file_id:fileId, file_name:file.name});
373
+ return r.data.url;
374
+ }
375
+
376
+ // Blob upload endpoint (for audio messages)
377
+ async function uploadBlob(file){
378
+ const form = new FormData(); form.append('file', file, file.name || ('blob_'+Date.now()+'.webm'));
379
+ const r = await axios.post('/upload_blob', form, { headers: {'Content-Type': 'multipart/form-data'} });
380
+ return r.data.url;
381
+ }
382
+
383
+ // Audio recording
384
+ let mediaRecorder = null; let recordedChunks = [];
385
+ const recordBtn = document.getElementById('recordBtn');
386
+ recordBtn.addEventListener('click', async ()=>{
387
+ if(!mediaRecorder){
388
+ try{
389
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
390
+ mediaRecorder = new MediaRecorder(stream);
391
+ mediaRecorder.ondataavailable = (e)=>{ if(e.data && e.data.size) recordedChunks.push(e.data); };
392
+ mediaRecorder.onstop = async ()=>{
393
+ const blob = new Blob(recordedChunks, {type: 'audio/webm'});
394
+ recordedChunks = [];
395
+ const file = new File([blob], 'voice_'+Date.now()+'.webm', {type: 'audio/webm'});
396
+ const fileUrl = await uploadBlob(file);
397
+ socket.emit('post_file_message', {username, avatar, room, text:'', file_path: fileUrl, file_name: file.name, mime: file.type});
398
+ };
399
+ mediaRecorder.start();
400
+ recordBtn.textContent = '⏹️';
401
+ }catch(err){ alert('Не удалось получить доступ к микрофону: '+err.message); }
402
+ } else if(mediaRecorder.state === 'recording'){
403
+ mediaRecorder.stop();
404
+ mediaRecorder = null; recordBtn.textContent='🔴';
405
+ }
406
+ });
407
+
408
+ // initial load
409
+ loadRecent();
410
+ </script>
411
+ </body>
412
+ </html>
413
+ """
414
+
415
+ # --- Server routes ---
416
+
417
+ @app.route("/")
418
+ def index():
419
+ return render_template_string(TEMPLATE)
420
+
421
+
422
+ @app.route("/manifest.json")
423
+ def manifest():
424
+ data = {
425
+ "name": "Mobile Messenger",
426
+ "short_name": "Messenger",
427
+ "start_url": "/",
428
+ "display": "standalone",
429
+ "background_color": "#ffffff",
430
+ "theme_color": "#0d6efd",
431
+ "icons": [
432
+ {"src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png"},
433
+ {"src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png"},
434
+ ],
435
+ }
436
+ return jsonify(data)
437
+
438
+
439
+ # --- File upload with chunking ---
440
+ @app.route("/upload_chunk", methods=["POST"])
441
+ def upload_chunk():
442
+ chunk = request.files.get("chunk")
443
+ file_id = request.form.get("file_id")
444
+ try:
445
+ index = int(request.form.get("index", "0"))
446
+ except Exception:
447
+ index = 0
448
+ total = int(request.form.get("total", "1"))
449
+ file_name = request.form.get("file_name") or "upload"
450
+ if not chunk or not file_id:
451
+ return ("Missing chunk or file_id", 400)
452
+ temp_dir = CHUNK_TEMP / file_id
453
+ temp_dir.mkdir(parents=True, exist_ok=True)
454
+ chunk_path = temp_dir / f"{index:06d}.part"
455
+ chunk.save(str(chunk_path))
456
+ return ("OK", 200)
457
+
458
+
459
+ @app.route("/upload_complete", methods=["POST"])
460
+ def upload_complete():
461
+ data = request.get_json(force=True) if request.is_json else request.form
462
+ file_id = data.get("file_id")
463
+ file_name = data.get("file_name") or f"file_{uuid.uuid4().hex}"
464
+ if not file_id:
465
+ return ("Missing file_id", 400)
466
+ temp_dir = CHUNK_TEMP / file_id
467
+ if not temp_dir.exists():
468
+ return ("No chunks found", 404)
469
+ parts = sorted(temp_dir.iterdir())
470
+ final_name = f"{datetime.utcnow().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex}_{file_name}"
471
+ final_path = UPLOAD_DIR / final_name
472
+ with open(final_path, "wb") as wf:
473
+ for p in parts:
474
+ with open(p, "rb") as rf:
475
+ wf.write(rf.read())
476
+ # cleanup
477
+ for p in parts:
478
+ try:
479
+ p.unlink()
480
+ except Exception:
481
+ pass
482
+ try:
483
+ temp_dir.rmdir()
484
+ except Exception:
485
+ pass
486
+ url = url_for("uploaded_file", filename=final_name, _external=True)
487
+ return jsonify({"url": url, "filename": final_name})
488
+
489
+
490
+ # Direct blob upload (for audio messages and small files)
491
+ @app.route("/upload_blob", methods=["POST"])
492
+ def upload_blob():
493
+ f = request.files.get("file")
494
+ if not f:
495
+ return ("No file", 400)
496
+ safe_name = f"{datetime.utcnow().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex}_{secure_filename(f.filename)}"
497
+ path = UPLOAD_DIR / safe_name
498
+ f.save(str(path))
499
+ url = url_for("uploaded_file", filename=safe_name, _external=True)
500
+ return jsonify({"url": url, "filename": safe_name})
501
+
502
+
503
+ # Serve uploaded files
504
+ @app.route("/uploads/<path:filename>")
505
+ def uploaded_file(filename):
506
+ return send_from_directory(app.config["UPLOAD_FOLDER"], filename)
507
+
508
+
509
+ # SocketIO events
510
+ @socketio.on("join")
511
+ def on_join(data):
512
+ room = data.get("room", "global")
513
+ join_room(room)
514
+ system_avatar = "https://api.dicebear.com/6.x/thumbs/svg?seed=System"
515
+ emit(
516
+ "message",
517
+ {
518
+ "id": str(uuid.uuid4()),
519
+ "username": "System",
520
+ "avatar": system_avatar,
521
+ "text": f"{data.get('username')} присоединился к чату",
522
+ "timestamp": datetime.utcnow().isoformat(),
523
+ "room": room,
524
+ },
525
+ room=room,
526
+ )
527
+
528
+
529
+ @socketio.on("post_message")
530
+ def on_post_message(data):
531
+ msg = {
532
+ "id": str(uuid.uuid4()),
533
+ "username": data.get("username"),
534
+ "avatar": data.get("avatar"),
535
+ "room": data.get("room", "global"),
536
+ "text": data.get("text"),
537
+ "file_path": None,
538
+ "file_name": None,
539
+ "mime": None,
540
+ "timestamp": datetime.utcnow().isoformat(),
541
+ "status": "sent",
542
+ }
543
+ save_message(msg)
544
+ emit("message", msg, room=msg["room"])
545
+
546
+
547
+ @socketio.on("post_file_message")
548
+ def on_post_file_message(data):
549
+ # Prefer explicit file_path from client
550
+ file_path = data.get("file_path")
551
+ file_name = data.get("file_name")
552
+ mime = data.get("mime")
553
+ if not file_path and file_name:
554
+ # try to find file by file_name
555
+ found = None
556
+ for f in UPLOAD_DIR.iterdir():
557
+ if file_name and file_name in f.name:
558
+ found = f
559
+ break
560
+ if found:
561
+ file_path = url_for("uploaded_file", filename=found.name, _external=True)
562
+ msg = {
563
+ "id": str(uuid.uuid4()),
564
+ "username": data.get("username"),
565
+ "avatar": data.get("avatar"),
566
+ "room": data.get("room", "global"),
567
+ "text": data.get("text", ""),
568
+ "file_path": file_path,
569
+ "file_name": file_name,
570
+ "mime": mime,
571
+ "timestamp": datetime.utcnow().isoformat(),
572
+ "status": "sent",
573
+ }
574
+ save_message(msg)
575
+ emit("message", msg, room=msg["room"])
576
+
577
+
578
+ @socketio.on("typing")
579
+ def on_typing(data):
580
+ room = data.get("room", "global")
581
+ emit("typing", {"username": data.get("username")}, room=room, include_self=False)
582
+
583
+
584
+ # history endpoint
585
+ @app.route("/history")
586
+ def history():
587
+ room = request.args.get("room", "global")
588
+ return jsonify(get_recent_messages(room))
589
+
590
+
591
+ # simple static serve
592
+ @app.route("/static/<path:p>")
593
+ def static_files(p):
594
+ pth = STATIC_DIR / p
595
+ if pth.exists():
596
+ return send_from_directory(str(STATIC_DIR), p)
597
+ return ("", 404)
598
+
599
+
600
+ # small helper for safe filenames (to avoid importing heavy libs)
601
+ def secure_filename(name: str) -> str:
602
+ # very small sanitizer: keep alnum, dot, dash, underscore
603
+ keep = []
604
+ for ch in name:
605
+ if ch.isalnum() or ch in "._-":
606
+ keep.append(ch)
607
+ out = "".join(keep)
608
+ if not out:
609
+ out = f"file_{uuid.uuid4().hex}"
610
+ return out
611
+
612
+
613
+ if __name__ == "__main__":
614
+ print("Starting Mobile Messenger on http://0.0.0.0:5000")
615
+ if _HAS_EVENTLET:
616
+ # monkey patch for eventlet (recommended if installed)
617
+ try:
618
+ eventlet.monkey_patch()
619
+ except Exception:
620
+ pass
621
+ # Run SocketIO server (eventlet or threading)
622
+ socketio.run(app, host="0.0.0.0", port=5000)