Starchik1 commited on
Commit
9c8f0a9
·
verified ·
1 Parent(s): ae79419

Create main.py

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