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

Update main.py

Browse files

"""
flask_messenger_single_file.py
Advanced single-file Flask messenger optimized for mobile (PWA-ready).

Features:
- Single-file Flask + Flask-SocketIO app
- SQLite message store
- Real-time chat via WebSocket (Socket.IO)
- File uploads with chunked upload support for large files
- Direct blob upload endpoint for audio messages
- Typing indicator, simple settings modal, avatars (DiceBear)
- Mobile-first responsive UI embedded in the file

Dependencies:
pip install flask flask-socketio
Optional (better concurrency):
pip install eventlet
Run:
python flask_messenger_single_file.py
Notes for Android (Pydroid3):
- If eventlet is unavailable, the app will use the 'threading' async mode.
- Open in mobile browser: http://<device-ip>:5000
"""

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

# Try to import eventlet for better Socket.IO behavior; fallback allowed
try:
import eventlet # optional
_HAS_EVENTLET = True
except Exception:
_HAS_EVENTLET = False

# Configuration
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"

os.makedirs(UPLOAD_DIR, exist_ok=True)
os.makedirs(STATIC_DIR, exist_ok=True)
CHUNK_TEMP.mkdir(exist_ok=True)

app = Flask(__name__)
app.config["SECRET_KEY"] = os.environ.get("FLASK_SECRET", "dev_secret_change_me")
app.config["UPLOAD_FOLDER"] = str(UPLOAD_DIR)

# Choose async mode
_async_mode = "eventlet" if _HAS_EVENTLET else "threading"
socketio = SocketIO(app, cors_allowed_origins="*", async_mode=_async_mode)

# --- Database helpers ---
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()


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()


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


init_db()

# --- UI Template (embedded) ---
TEMPLATE = r"""
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<title>Mobile Messenger</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0d6efd" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
:root{--accent:#0d6efd}
body{background:#f6f7fb;height:100vh;display:flex;flex-direction:column;margin:0}
.app{max-width:900px;margin:0 auto;flex:1;display:flex;flex-direction:column;min-height:0}
header{background:linear-gradient(90deg,var(--accent),#0056b3);color:#fff;padding:.9rem}
.chat-window{flex:1;overflow:auto;padding:1rem;display:flex;flex-direction:column;gap:.6rem;min-height:0}
.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}
.from-me{align-self:flex-end;background:#dff3ff}
.from-other{align-self:flex-start;background:#fff}
.meta{font-size:.75rem;color:#666;margin-bottom:.25rem;display:flex;align-items:center;gap:.5rem}
.input-area{padding:.6rem;background:#fff;border-top:1px solid #eee}
.attachments{display:flex;gap:.5rem;align-items:center}
.file-preview{max-width:160px;border-radius:8px;overflow:hidden}
.typing{font-style:italic;color:#666;font-size:.85rem}
.avatar{width:36px;height:36px;border-radius:50%;object-fit:cover}
.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)}
.modal.show{display:flex}
.modal-dialog{max-width:420px;width:100%}


@media
(max-width:480px){ .msg{max-width:92%} header{padding:.7rem} }
</style>
</head>
<body>
<div class="app shadow-sm d-flex flex-column">
<header class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2">
<img id="roomAvatar" src="" class="avatar" alt="">
<div>
<div id="roomName">Mobile Chat</div>
<div style="font-size:.75rem;opacity:.9">Fast, private, PWA-ready</div>
</div>
</div>
<div>
<button id="btnSettings" class="btn btn-sm btn-light">Настройки</button>
</div>
</header>

<div id="chat" class="chat-window" role="log" aria-live="polite"></div>

<div class="input-area">
<div id="typing" class="typing"></div>
<form id="composer" class="d-flex gap-2 align-items-center">
<div class="attachments d-flex align-items-center">
<label class="btn btn-outline-secondary btn-sm mb-0" title="Прикрепить файл">
📎<input id="fileInput" type="file" hidden>
</label>
<button id="recordBtn" class="btn btn-outline-danger btn-sm mb-0" type="button" title="Записать голос">🔴</button>
</div>
<input id="message" autocomplete="off" placeholder="Напишите сообщение..." class="form-control form-control-sm" />
<button class="btn btn-primary btn-sm" type="submit">Отправить</button>
</form>
</div>
</div>

<!-- Settings Modal -->
<div class="modal" id="settingsModal" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content bg-white rounded p-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="m-0">Настройки</h5>
<button id="closeSettings" class="btn btn-sm btn-outline-secondary">✖</button>
</div>
<div class="mb-2">
<label class="form-label">Никнейм</label>
<input id="setUsername" class="form-control" />
</div>
<div class="mb-2">
<label class="form-label">URL аватарки (оставьте пустым для генерации)</label>
<input id="setAvatar" class="form-control" placeholder="https://..." />
</div>
<div class="d-flex justify-content-end">
<button id="saveSettings" class="btn btn-primary">Сохранить</button>
</div>
</div>
</div>
</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>
const socket = io();
let username = localStorage.getItem('mm_username') || '';
let avatar = localStorage.getItem('mm_avatar') || '';
const room = 'global';

function nano(){return Math.random().toString(36).slice(2,10);}
if(!username){ username = 'User-'+ nano(); }
if(!avatar){ avatar = 'https://api.dicebear.com/6.x/thumbs/svg?seed='+encodeURIComponent(username); }
localStorage.setItem('mm_username', username);
localStorage.setItem('mm_avatar', avatar);

document.getElementById('roomAvatar').src = avatar;
document.getElementById('roomName').innerText = room;

// Settings modal handlers
const btnSettings = document.getElementById('btnSettings');
const settingsModal = document.getElementById('settingsModal');
const closeSettings = document.getElementById('closeSettings');
const saveSettings = document.getElementById('saveSettings');
const setUsername = document.getElementById('setUsername');
const setAvatar = document.getElementById('setAvatar');

btnSettings.addEventListener('click', ()=>{
setUsername.value = localStorage.getItem('mm_username') || '';
setAvatar.value = localStorage.getItem('mm_avatar') || '';
settingsModal.classList.add('show');
settingsModal.setAttribute('aria-hidden','false');
});
closeSettings.addEventListener('click', ()=>{ settingsModal.classList.remove('show'); settingsModal.setAttribute('aria-hidden','true'); });
saveSettings.addEventListener('click', ()=>{
const nu = setUsername.value.trim() || ('User-'+nano());
const na = setAvatar.value.trim() || ('https://api.dicebear.com/6.x/thumbs/svg?seed='+encodeURIComponent(nu));
username = nu; avatar = na;
localStorage.setItem('mm_username', username);
localStorage.setItem('mm_avatar', avatar);
document.getElementById('roomAvatar').src = avatar;
settingsModal.classList.remove('show');
settingsModal.setAttribute('aria-hidden','true');
});

// load recent messages
async function loadRecent(){
try{
const r = await axios.get('/history?room='+encodeURIComponent(room));
const list = r.data||[];
const chat = document.getElementById('chat');
c

Files changed (1) hide show
  1. main.py +0 -626
main.py CHANGED
@@ -1,626 +0,0 @@
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)