Starchik1 commited on
Commit
65c9994
·
verified ·
1 Parent(s): d5d28d6

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +293 -379
main.py CHANGED
@@ -1,147 +1,75 @@
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" />
@@ -164,10 +92,11 @@ TEMPLATE = r"""
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>
@@ -184,27 +113,23 @@ TEMPLATE = r"""
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">
@@ -224,17 +149,34 @@ TEMPLATE = r"""
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);
@@ -243,13 +185,24 @@ localStorage.setItem('mm_avatar', avatar);
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') || '';
@@ -267,6 +220,7 @@ saveSettings.addEventListener('click', ()=>{
267
  document.getElementById('roomAvatar').src = avatar;
268
  settingsModal.classList.remove('show');
269
  settingsModal.setAttribute('aria-hidden','true');
 
270
  });
271
 
272
  // load recent messages
@@ -292,7 +246,8 @@ function addMessageToDom(m){
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');
@@ -319,14 +274,123 @@ function addMessageToDom(m){
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');
@@ -342,7 +406,6 @@ composer.addEventListener('submit', async (e)=>{
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='';
@@ -380,7 +443,7 @@ async function uploadBlob(file){
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 ()=>{
@@ -407,216 +470,67 @@ recordBtn.addEventListener('click', async ()=>{
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)
 
1
+ """ flask_messenger_single_file.py Advanced single-file Flask messenger optimized for mobile (PWA-ready).
 
 
2
 
3
  Features:
4
+
5
+ Single-file Flask + Flask-SocketIO app
6
+
7
+ SQLite message store
8
+
9
+ Real-time chat via WebSocket (Socket.IO)
10
+
11
+ File uploads with chunked upload support for large files
12
+
13
+ Direct blob upload endpoint for audio messages
14
+
15
+ Typing indicator, simple settings modal, avatars (DiceBear)
16
+
17
+ Presence tracking (online users per room)
18
+
19
+ Peer-to-peer audio calls via WebRTC (signaling over Socket.IO)
20
+
21
+ Mobile-first responsive UI embedded in the file
22
+
23
+
24
+ Dependencies: pip install flask flask-socketio eventlet
25
+
26
+ Run: python flask_messenger_single_file.py
27
+
28
+ Notes for deployment (Hugging Face Spaces with Gunicorn):
29
+
30
+ Use eventlet worker in Gunicorn: gunicorn -k eventlet -w 1 -b 0.0.0.0:7860 main:app
31
+
32
+ Ensure eventlet is in requirements.txt and eventlet.monkey_patch() is called before SocketIO creation.
33
+
34
+
35
  """
36
 
37
+ import os import uuid import sqlite3 import pathlib from datetime import datetime from flask import ( Flask, request, url_for, send_from_directory, jsonify, render_template_string, ) from flask_socketio import SocketIO, emit, join_room, leave_room
38
+
39
+ Try to import eventlet for better Socket.IO behavior; fallback allowed
40
+
41
+ try: import eventlet # optional eventlet.monkey_patch() _HAS_EVENTLET = True except Exception: _HAS_EVENTLET = False
42
+
43
+ Configuration
44
+
45
+ BASE_DIR = pathlib.Path(file).parent.resolve() UPLOAD_DIR = BASE_DIR / "uploads" DB_PATH = BASE_DIR / "chat.db" STATIC_DIR = BASE_DIR / "static" CHUNK_TEMP = BASE_DIR / "chunk_temp"
46
+
47
+ os.makedirs(UPLOAD_DIR, exist_ok=True) os.makedirs(STATIC_DIR, exist_ok=True) CHUNK_TEMP.mkdir(exist_ok=True)
48
+
49
+ app = Flask(name) app.config["SECRET_KEY"] = os.environ.get("FLASK_SECRET", "dev_secret_change_me") app.config["UPLOAD_FOLDER"] = str(UPLOAD_DIR)
50
+
51
+ Choose async mode
52
+
53
+ _async_mode = "eventlet" if _HAS_EVENTLET else "threading" socketio = SocketIO(app, cors_allowed_origins="*", async_mode=_async_mode)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
+ In-memory presence: {room: {username: sid}}
56
+
57
+ PRESENCE = {}
58
+
59
+ --- Database helpers ---
60
+
61
+ def init_db(): conn = sqlite3.connect(DB_PATH) cur = conn.cursor() cur.execute( """ CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, username TEXT, avatar TEXT, room TEXT, text TEXT, file_path TEXT, file_name TEXT, mime TEXT, timestamp TEXT, status TEXT ) """ ) conn.commit() conn.close()
62
+
63
+ def save_message(msg: dict): conn = sqlite3.connect(DB_PATH) cur = conn.cursor() cur.execute( """INSERT INTO messages (id, username, avatar, room, text, file_path, file_name, mime, timestamp, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( msg["id"], msg.get("username"), msg.get("avatar"), msg.get("room"), msg.get("text"), msg.get("file_path"), msg.get("file_name"), msg.get("mime"), msg.get("timestamp"), msg.get("status", "sent"), ), ) conn.commit() conn.close()
64
+
65
+ def get_recent_messages(room: str, limit: int = 200): conn = sqlite3.connect(DB_PATH) cur = conn.cursor() cur.execute( "SELECT id, username, avatar, text, file_path, file_name, mime, timestamp, status FROM messages WHERE room=? ORDER BY timestamp ASC LIMIT ?", (room, limit), ) rows = cur.fetchall() conn.close() out = [] for r in rows: out.append( { "id": r[0], "username": r[1], "avatar": r[2], "text": r[3], "file_path": r[4], "file_name": r[5], "mime": r[6], "timestamp": r[7], "status": r[8], } ) return out
66
 
67
  init_db()
68
 
69
+ --- UI Template (embedded) ---
70
+
71
+ TEMPLATE = r""" <!doctype html>
72
+
73
  <html lang="ru">
74
  <head>
75
  <meta charset="utf-8" />
 
92
  .attachments{display:flex;gap:.5rem;align-items:center}
93
  .file-preview{max-width:160px;border-radius:8px;overflow:hidden}
94
  .typing{font-style:italic;color:#666;font-size:.85rem}
95
+ .avatar{width:36px;height:36px;border-radius:50%;object-fit:cover;cursor:pointer}
96
  .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)}
97
  .modal.show{display:flex}
98
  .modal-dialog{max-width:420px;width:100%}
99
+ .call-bar{position:fixed;left:12px;right:12px;bottom:12px;background:#fff;border-radius:10px;padding:.6rem;box-shadow:0 6px 18px rgba(0,0,0,.12);display:flex;align-items:center;gap:.6rem}
100
  @media (max-width:480px){ .msg{max-width:92%} header{padding:.7rem} }
101
  </style>
102
  </head>
 
113
  <div>
114
  <button id="btnSettings" class="btn btn-sm btn-light">Настройки</button>
115
  </div>
116
+ </header><div id="chat" class="chat-window" role="log" aria-live="polite"></div>
117
+
118
+ <div class="input-area">
119
+ <div id="typing" class="typing"></div>
120
+ <form id="composer" class="d-flex gap-2 align-items-center">
121
+ <div class="attachments d-flex align-items-center">
122
+ <label class="btn btn-outline-secondary btn-sm mb-0" title="Прикрепить файл">
123
+ 📎<input id="fileInput" type="file" hidden>
124
+ </label>
125
+ <button id="recordBtn" class="btn btn-outline-danger btn-sm mb-0" type="button" title="Записать голос">🔴</button>
 
 
 
 
 
 
126
  </div>
127
+ <input id="message" autocomplete="off" placeholder="Напишите сообщение..." class="form-control form-control-sm" />
128
+ <button class="btn btn-primary btn-sm" type="submit">Отправить</button>
129
+ </form>
130
+ </div>
131
 
132
+ </div> <!-- Settings Modal --> <div class="modal" id="settingsModal" aria-hidden="true">
 
133
  <div class="modal-dialog">
134
  <div class="modal-content bg-white rounded p-3">
135
  <div class="d-flex justify-content-between align-items-center mb-2">
 
149
  </div>
150
  </div>
151
  </div>
152
+ </div> <!-- Call bar (shows during active call) --> <div id="callBar" class="call-bar" style="display:none">
153
+ <div id="callParticipants">Вызов: <span id="callNames"></span></div>
154
+ <div style="margin-left:auto">
155
+ <button id="endCallBtn" class="btn btn-sm btn-danger">Завершить</button>
156
+ </div>
157
+ </div><script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script><script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script><script>
158
+ // Polyfill for getUserMedia in case browser exposes legacy prefixes or lacks navigator.mediaDevices
159
+ if (!navigator.mediaDevices) {
160
+ navigator.mediaDevices = {};
161
+ }
162
+ if (!navigator.mediaDevices.getUserMedia) {
163
+ navigator.mediaDevices.getUserMedia = function(constraints) {
164
+ var getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
165
+ if (!getUserMedia) {
166
+ return Promise.reject(new Error('getUserMedia is not implemented in this browser'));
167
+ }
168
+ return new Promise(function(resolve, reject) {
169
+ getUserMedia.call(navigator, constraints, resolve, reject);
170
+ });
171
+ }
172
+ }
173
 
 
 
 
174
  const socket = io();
175
  let username = localStorage.getItem('mm_username') || '';
176
  let avatar = localStorage.getItem('mm_avatar') || '';
177
  const room = 'global';
178
 
179
+ function nano(){return Math.random().toString(36).slice(2,10);}
180
  if(!username){ username = 'User-'+ nano(); }
181
  if(!avatar){ avatar = 'https://api.dicebear.com/6.x/thumbs/svg?seed='+encodeURIComponent(username); }
182
  localStorage.setItem('mm_username', username);
 
185
  document.getElementById('roomAvatar').src = avatar;
186
  document.getElementById('roomName').innerText = room;
187
 
188
+ // Presence & user list
189
+ let onlineUsers = {}; // {username: true}
190
+
191
+ // WebRTC state
192
+ const RTC_CONFIG = { iceServers: [{urls: 'stun:stun.l.google.com:19302'}] };
193
+ const peers = {}; // username -> RTCPeerConnection
194
+ let localStream = null;
195
+
196
+ // UI elements
197
  const btnSettings = document.getElementById('btnSettings');
198
  const settingsModal = document.getElementById('settingsModal');
199
  const closeSettings = document.getElementById('closeSettings');
200
  const saveSettings = document.getElementById('saveSettings');
201
  const setUsername = document.getElementById('setUsername');
202
  const setAvatar = document.getElementById('setAvatar');
203
+ const callBar = document.getElementById('callBar');
204
+ const callNames = document.getElementById('callNames');
205
+ const endCallBtn = document.getElementById('endCallBtn');
206
 
207
  btnSettings.addEventListener('click', ()=>{
208
  setUsername.value = localStorage.getItem('mm_username') || '';
 
220
  document.getElementById('roomAvatar').src = avatar;
221
  settingsModal.classList.remove('show');
222
  settingsModal.setAttribute('aria-hidden','true');
223
+ socket.emit('presence_update', {room, username});
224
  });
225
 
226
  // load recent messages
 
246
  el.className = 'msg '+(fromMe? 'from-me':'from-other');
247
  const meta = document.createElement('div'); meta.className='meta';
248
  const avatarSrc = safeAvatarFor(m);
249
+ // avatar img has data-username so clicking initiates a call to that user
250
+ meta.innerHTML = `<img src="${avatarSrc}" class="avatar" data-username="${m.username||''}" onclick="initiateCallPrompt('${m.username||''}')"> <strong>${m.username||'Anonymous'}</strong> · <span>${new Date(m.timestamp).toLocaleString()}</span>`;
251
  const body = document.createElement('div');
252
  if(m.text) {
253
  const p = document.createElement('div');
 
274
  chat.scrollTop = chat.scrollHeight;
275
  }
276
 
277
+ // prompt user to call
278
+ window.initiateCallPrompt = async function(target){
279
+ if(!target || target === username) return alert('Нельзя звонить себе');
280
+ if(!onlineUsers[target]) return alert('Пользователь офлайн');
281
+ const ok = confirm(target + ' — вызвать?');
282
+ if(!ok) return;
283
+ startCall(target);
284
+ }
285
+
286
+ // Socket handlers for presence & signaling
287
  socket.on('connect', ()=>{
288
  socket.emit('join', {room, username, avatar});
289
  });
290
 
291
+ socket.on('user_list', (data)=>{
292
+ onlineUsers = data || {};
293
+ // you could update UI for online users here
294
+ });
295
+
296
+ socket.on('webrtc-offer', async (data)=>{
297
+ // incoming offer: data={from, sdp}
298
+ const from = data.from;
299
+ const sdp = data.sdp;
300
+ const accept = confirm('Входящий вызов от '+from+' — принять?');
301
+ if(!accept){ socket.emit('webrtc-reject', {to: from, room}); return; }
302
+ await ensureLocalStream();
303
+ const pc = createPeerConnection(from);
304
+ await pc.setRemoteDescription(new RTCSessionDescription(sdp));
305
+ const answer = await pc.createAnswer();
306
+ await pc.setLocalDescription(answer);
307
+ socket.emit('webrtc-answer', {to: from, sdp: pc.localDescription, room});
308
+ showCallBar();
309
+ });
310
+
311
+ socket.on('webrtc-answer', async (data)=>{
312
+ const from = data.from; const sdp = data.sdp;
313
+ const pc = peers[from];
314
+ if(pc){ await pc.setRemoteDescription(new RTCSessionDescription(sdp)); }
315
+ });
316
+
317
+ socket.on('webrtc-ice', async (data)=>{
318
+ const from = data.from; const candidate = data.candidate;
319
+ const pc = peers[from];
320
+ if(pc && candidate){
321
+ try{ await pc.addIceCandidate(new RTCIceCandidate(candidate)); }catch(e){ console.warn('ICE add failed', e); }
322
+ }
323
+ });
324
+
325
+ socket.on('webrtc-reject', (data)=>{
326
+ alert('Вызов отклонён пользователем.');
327
+ stopCall();
328
+ });
329
+
330
+ // helper: ensure local audio stream
331
+ async function ensureLocalStream(){
332
+ if(localStream) return localStream;
333
+ try{
334
+ localStream = await navigator.mediaDevices.getUserMedia({audio:true});
335
+ return localStream;
336
+ }catch(e){ alert('Невозможно получить доступ к микрофону: '+e.message); throw e; }
337
+ }
338
+
339
+ function createPeerConnection(peerName){
340
+ const pc = new RTCPeerConnection(RTC_CONFIG);
341
+ peers[peerName] = pc;
342
+ // add local tracks
343
+ if(localStream){ localStream.getTracks().forEach(t => pc.addTrack(t, localStream)); }
344
+ pc.onicecandidate = (e)=>{
345
+ if(e.candidate){ socket.emit('webrtc-ice', {to: peerName, candidate: e.candidate, room}); }
346
+ };
347
+ pc.ontrack = (e)=>{
348
+ // create or reuse audio element
349
+ let audio = document.getElementById('audio_'+peerName);
350
+ if(!audio){ audio = document.createElement('audio'); audio.id = 'audio_'+peerName; audio.autoplay = true; audio.controls = true; audio.style.display='block';
351
+ // append to call bar area
352
+ callBar.insertBefore(audio, callBar.firstChild);
353
+ }
354
+ audio.srcObject = e.streams[0];
355
+ };
356
+ pc.onconnectionstatechange = ()=>{
357
+ if(pc.connectionState === 'disconnected' || pc.connectionState === 'failed' || pc.connectionState === 'closed'){
358
+ removePeer(peerName);
359
+ }
360
+ };
361
+ return pc;
362
+ }
363
+
364
+ async function startCall(target){
365
+ await ensureLocalStream();
366
+ const pc = createPeerConnection(target);
367
+ // create offer
368
+ const offer = await pc.createOffer();
369
+ await pc.setLocalDescription(offer);
370
+ socket.emit('webrtc-offer', {to: target, sdp: pc.localDescription, room});
371
+ showCallBar(target);
372
+ }
373
+
374
+ function showCallBar(name){
375
+ callBar.style.display='flex';
376
+ if(name) callNames.innerText = name; else callNames.innerText = Object.keys(peers).join(', ');
377
+ }
378
+
379
+ function removePeer(name){
380
+ try{ const a = document.getElementById('audio_'+name); if(a) a.remove(); }catch(e){}
381
+ if(peers[name]){ try{ peers[name].close(); }catch(e){} delete peers[name]; }
382
+ if(Object.keys(peers).length===0){ callBar.style.display='none'; }
383
+ }
384
+
385
+ function stopCall(){
386
+ for(const p in peers){ try{ peers[p].close(); }catch(e){} removePeer(p); }
387
+ if(localStream){ localStream.getTracks().forEach(t=>t.stop()); localStream=null; }
388
+ callBar.style.display='none';
389
+ callNames.innerText='';
390
+ }
391
+ endCallBtn.addEventListener('click', ()=>{ stopCall(); });
392
 
393
+ // composer & uploads (unchanged behaviour)
394
  const composer = document.getElementById('composer');
395
  const messageInput = document.getElementById('message');
396
  const fileInput = document.getElementById('fileInput');
 
406
  const text = messageInput.value.trim();
407
  if(fileInput.files.length){
408
  const file = fileInput.files[0];
 
409
  const fileUrl = await uploadFileChunked(file);
410
  socket.emit('post_file_message', {username, avatar, room, text:'', file_path: fileUrl, file_name: file.name, mime: file.type});
411
  fileInput.value='';
 
443
  return r.data.url;
444
  }
445
 
446
+ // Audio recording (voice messages)
447
  let mediaRecorder = null; let recordedChunks = [];
448
  const recordBtn = document.getElementById('recordBtn');
449
  recordBtn.addEventListener('click', async ()=>{
 
470
 
471
  // initial load
472
  loadRecent();
473
+
474
+ </script></body>
475
  </html>
476
+ """--- Server routes ---
477
 
478
+ @app.route("/") def index(): return render_template_string(TEMPLATE)
479
+
480
+ @app.route("/manifest.json") def manifest(): data = { "name": "Mobile Messenger", "short_name": "Messenger", "start_url": "/", "display": "standalone", "background_color": "#ffffff", "theme_color": "#0d6efd", "icons": [ {"src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png"}, {"src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png"}, ], } return jsonify(data)
481
+
482
+ --- File upload with chunking ---
483
+
484
+ @app.route("/upload_chunk", methods=["POST"]) def upload_chunk(): chunk = request.files.get("chunk") file_id = request.form.get("file_id") try: index = int(request.form.get("index", "0")) except Exception: index = 0 total = int(request.form.get("total", "1")) file_name = request.form.get("file_name") or "upload" if not chunk or not file_id: return ("Missing chunk or file_id", 400) temp_dir = CHUNK_TEMP / file_id temp_dir.mkdir(parents=True, exist_ok=True) chunk_path = temp_dir / f"{index:06d}.part" chunk.save(str(chunk_path)) return ("OK", 200)
485
+
486
+ @app.route("/upload_complete", methods=["POST"]) def upload_complete(): data = request.get_json(force=True) if request.is_json else request.form file_id = data.get("file_id") file_name = data.get("file_name") or f"file_{uuid.uuid4().hex}" if not file_id: return ("Missing file_id", 400) temp_dir = CHUNK_TEMP / file_id if not temp_dir.exists(): return ("No chunks found", 404) parts = sorted(temp_dir.iterdir()) final_name = f"{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex}{file_name}" final_path = UPLOAD_DIR / final_name with open(final_path, "wb") as wf: for p in parts: with open(p, "rb") as rf: wf.write(rf.read()) # cleanup for p in parts: try: p.unlink() except Exception: pass try: temp_dir.rmdir() except Exception: pass url = url_for("uploaded_file", filename=final_name, _external=True) return jsonify({"url": url, "filename": final_name})
487
+
488
+ Direct blob upload (for audio messages and small files)
489
+
490
+ @app.route("/upload_blob", methods=["POST"]) def upload_blob(): f = request.files.get("file") if not f: return ("No file", 400) safe_name = f"{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex}{secure_filename(f.filename)}" path = UPLOAD_DIR / safe_name f.save(str(path)) url = url_for("uploaded_file", filename=safe_name, _external=True) return jsonify({"url": url, "filename": safe_name})
491
+
492
+ Serve uploaded files
493
+
494
+ @app.route("/uploads/path:filename") def uploaded_file(filename): return send_from_directory(app.config["UPLOAD_FOLDER"], filename)
495
+
496
+ SocketIO events (presence and signaling)
497
+
498
+ @socketio.on("join") def on_join(data): room = data.get("room", "global") user = data.get("username") sid = request.sid if room not in PRESENCE: PRESENCE[room] = {} PRESENCE[room][user] = sid join_room(room) system_avatar = "https://api.dicebear.com/6.x/thumbs/svg?seed=System" emit( "message", { "id": str(uuid.uuid4()), "username": "System", "avatar": system_avatar, "text": f"{user} присоединился к чату", "timestamp": datetime.utcnow().isoformat(), "room": room, }, room=room, ) # broadcast updated user list emit('user_list', list(PRESENCE[room].keys()), room=room)
499
+
500
+ @socketio.on('presence_update') def on_presence_update(data): room = data.get('room','global') user = data.get('username') if room not in PRESENCE: PRESENCE[room] = {} PRESENCE[room][user] = request.sid emit('user_list', list(PRESENCE[room].keys()), room=room)
501
+
502
+ @socketio.on('disconnect') def on_disconnect(): sid = request.sid # find and remove user from PRESENCE for room, d in list(PRESENCE.items()): to_remove = [u for u,s in d.items() if s==sid] for u in to_remove: d.pop(u, None) emit('user_list', list(d.keys()), room=room)
503
+
504
+ Signaling: offer, answer, ice, reject
505
+
506
+ @socketio.on('webrtc-offer') def on_webrtc_offer(data): to = data.get('to') room = data.get('room','global') sender = data.get('from') or None sdp = data.get('sdp') target_sid = PRESENCE.get(room, {}).get(to) if target_sid: emit('webrtc-offer', {'from': sender, 'sdp': sdp}, to=target_sid)
507
+
508
+ @socketio.on('webrtc-answer') def on_webrtc_answer(data): to = data.get('to') room = data.get('room','global') sender = data.get('from') or None sdp = data.get('sdp') target_sid = PRESENCE.get(room, {}).get(to) if target_sid: emit('webrtc-answer', {'from': sender, 'sdp': sdp}, to=target_sid)
509
+
510
+ @socketio.on('webrtc-ice') def on_webrtc_ice(data): to = data.get('to') room = data.get('room','global') sender = data.get('from') or None candidate = data.get('candidate') target_sid = PRESENCE.get(room, {}).get(to) if target_sid: emit('webrtc-ice', {'from': sender, 'candidate': candidate}, to=target_sid)
511
+
512
+ @socketio.on('webrtc-reject') def on_webrtc_reject(data): to = data.get('to') room = data.get('room','global') target_sid = PRESENCE.get(room, {}).get(to) if target_sid: emit('webrtc-reject', {}, to=target_sid)
513
+
514
+ @socketio.on('post_message') def on_post_message(data): msg = { "id": str(uuid.uuid4()), "username": data.get("username"), "avatar": data.get("avatar"), "room": data.get("room", "global"), "text": data.get("text"), "file_path": None, "file_name": None, "mime": None, "timestamp": datetime.utcnow().isoformat(), "status": "sent", } save_message(msg) emit("message", msg, room=msg["room"])
515
+
516
+ @socketio.on('post_file_message') def on_post_file_message(data): file_path = data.get('file_path') file_name = data.get('file_name') mime = data.get('mime') if not file_path and file_name: found = None for f in UPLOAD_DIR.iterdir(): if file_name and file_name in f.name: found = f break if found: file_path = url_for('uploaded_file', filename=found.name, _external=True) msg = { "id": str(uuid.uuid4()), "username": data.get("username"), "avatar": data.get("avatar"), "room": data.get("room", "global"), "text": data.get("text", ""), "file_path": file_path, "file_name": file_name, "mime": mime, "timestamp": datetime.utcnow().isoformat(), "status": "sent", } save_message(msg) emit("message", msg, room=msg["room"])
517
+
518
+ @socketio.on('typing') def on_typing(data): room = data.get('room', 'global') emit('typing', {'username': data.get('username')}, room=room, include_self=False)
519
+
520
+ history endpoint
521
+
522
+ @app.route("/history") def history(): room = request.args.get("room", "global") return jsonify(get_recent_messages(room))
523
+
524
+ online list endpoint
525
+
526
+ @app.route('/online') def online_list(): room = request.args.get('room','global') return jsonify(list(PRESENCE.get(room, {}).keys()))
527
+
528
+ simple static serve
529
+
530
+ @app.route("/static/path:p") def static_files(p): pth = STATIC_DIR / p if pth.exists(): return send_from_directory(str(STATIC_DIR), p) return ("", 404)
531
+
532
+ small helper for safe filenames (to avoid importing heavy libs)
533
+
534
+ def secure_filename(name: str) -> str: keep = [] for ch in (name or ''): if ch.isalnum() or ch in ".-": keep.append(ch) out = "".join(keep) if not out: out = f"file{uuid.uuid4().hex}" return out
535
+
536
+ if name == "main": print("Starting Mobile Messenger on http://0.0.0.0:5000") # Run SocketIO server (eventlet or threading) socketio.run(app, host="0.0.0.0", port=5000)