wsbuilder 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
wsbuilder/ws_demo.py ADDED
@@ -0,0 +1,1837 @@
1
+ # enhanced_server.py (versión basada en clases Threading + ORM SQLite)
2
+ # Servidor HTTP + WebSocket + REST con funcionalidades WebSocket ampliadas:
3
+ # - text / binary
4
+ # - fragmentation (continuation frames)
5
+ # - control frames: ping/pong/close (parseo de close code + reason)
6
+ # - negociación de subprotocolos (Sec-WebSocket-Protocol)
7
+ # - API REST para listar clientes, broadcast, ping, cerrar
8
+ # - ORM SQLite con concurrencia y transacciones anidadas
9
+ # UNICAS librerías externas: socket, threading, uuid, time, json, sqlite3
10
+
11
+ import socket
12
+ import threading
13
+ import uuid
14
+ import time
15
+ import json
16
+ import sqlite3
17
+
18
+ HOST = '0.0.0.0'
19
+ PORT = 8765
20
+
21
+ MAGIC_WS = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
22
+
23
+ # ============================================================
24
+ # SHA1 (implementación pura en Python para no importar hashlib)
25
+ # ============================================================
26
+
27
+ def _left_rotate(n, b):
28
+ return ((n << b) | (n >> (32 - b))) & 0xffffffff
29
+
30
+ def sha1(data_bytes):
31
+ message = bytearray(data_bytes)
32
+ orig_len_bits = (8 * len(message)) & 0xffffffffffffffff
33
+ message.append(0x80)
34
+ while (len(message) * 8) % 512 != 448:
35
+ message.append(0)
36
+ message += orig_len_bits.to_bytes(8, 'big')
37
+
38
+ h0 = 0x67452301
39
+ h1 = 0xEFCDAB89
40
+ h2 = 0x98BADCFE
41
+ h3 = 0x10325476
42
+ h4 = 0xC3D2E1F0
43
+
44
+ for i in range(0, len(message), 64):
45
+ w = [0] * 80
46
+ chunk = message[i:i+64]
47
+ for j in range(16):
48
+ w[j] = int.from_bytes(chunk[j*4:(j+1)*4], 'big')
49
+ for j in range(16, 80):
50
+ w[j] = _left_rotate(w[j-3] ^ w[j-8] ^ w[j-14] ^ w[j-16], 1)
51
+
52
+ a, b, c, d, e = h0, h1, h2, h3, h4
53
+ for t in range(80):
54
+ if 0 <= t <= 19:
55
+ f = (b & c) | ((~b) & d)
56
+ k = 0x5A827999
57
+ elif 20 <= t <= 39:
58
+ f = b ^ c ^ d
59
+ k = 0x6ED9EBA1
60
+ elif 40 <= t <= 59:
61
+ f = (b & c) | (b & d) | (c & d)
62
+ k = 0x8F1BBCDC
63
+ else:
64
+ f = b ^ c ^ d
65
+ k = 0xCA62C1D6
66
+ tmp = (_left_rotate(a, 5) + f + e + k + w[t]) & 0xffffffff
67
+ e = d
68
+ d = c
69
+ c = _left_rotate(b, 30)
70
+ b = a
71
+ a = tmp
72
+
73
+ h0 = (h0 + a) & 0xffffffff
74
+ h1 = (h1 + b) & 0xffffffff
75
+ h2 = (h2 + c) & 0xffffffff
76
+ h3 = (h3 + d) & 0xffffffff
77
+ h4 = (h4 + e) & 0xffffffff
78
+
79
+ digest = b''.join(x.to_bytes(4, 'big') for x in [h0, h1, h2, h3, h4])
80
+ return digest
81
+
82
+ # ============================================================
83
+ # Base64 (implementación mínima para bytes -> base64 str)
84
+ # ============================================================
85
+
86
+ B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
87
+
88
+ def base64_encode(data_bytes):
89
+ res = []
90
+ i = 0
91
+ n = len(data_bytes)
92
+ while i < n:
93
+ b = data_bytes[i:i+3]
94
+ i += 3
95
+ pad = 3 - len(b)
96
+ val = 0
97
+ for x in b:
98
+ val = (val << 8) + x
99
+ val <<= (pad * 8)
100
+ for j in range(18, -1, -6):
101
+ idx = (val >> j) & 0x3F
102
+ res.append(B64_ALPHABET[idx])
103
+ if pad:
104
+ res[-pad:] = '=' * pad
105
+ return ''.join(res)
106
+
107
+ # ============================================================
108
+ # Utilidades WebSocket frames
109
+ # ============================================================
110
+
111
+ def recv_exact(conn, n):
112
+ data = b''
113
+ while len(data) < n:
114
+ chunk = conn.recv(n - len(data))
115
+ if not chunk:
116
+ raise ConnectionError("connection closed")
117
+ data += chunk
118
+ return data
119
+
120
+ def read_ws_frame_raw(conn):
121
+ """
122
+ Lee un único frame y devuelve (fin, opcode, payload_bytes, masked_flag, mask_bytes).
123
+ No realiza ensamblado de fragmentation (eso lo hace el loop).
124
+ """
125
+ hdr = recv_exact(conn, 2)
126
+ b1, b2 = hdr[0], hdr[1]
127
+ fin = (b1 >> 7) & 1
128
+ opcode = b1 & 0x0f
129
+ masked = (b2 >> 7) & 1
130
+ payload_len = b2 & 0x7f
131
+
132
+ if payload_len == 126:
133
+ ext = recv_exact(conn, 2)
134
+ payload_len = int.from_bytes(ext, 'big')
135
+ elif payload_len == 127:
136
+ ext = recv_exact(conn, 8)
137
+ payload_len = int.from_bytes(ext, 'big')
138
+
139
+ mask = None
140
+ if masked:
141
+ mask = recv_exact(conn, 4)
142
+
143
+ payload = b''
144
+ if payload_len:
145
+ payload = recv_exact(conn, payload_len)
146
+ if masked and mask:
147
+ payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
148
+ return fin, opcode, payload, bool(masked), mask
149
+
150
+ def make_ws_frame_bytes(opcode, payload=b''):
151
+ """
152
+ Construye un frame desde el servidor hacia el cliente (no enmascarado).
153
+ opcode: int (1=text,2=binary,8=close,9=ping,10=pong,0=continuation)
154
+ payload: bytes
155
+ """
156
+ fin = 0x80 # siempre FIN=1 salvo que se quiera fragmentar por el servidor
157
+ b1 = fin | (opcode & 0x0f)
158
+ payload_len = len(payload)
159
+ header = bytes([b1])
160
+
161
+ if payload_len <= 125:
162
+ header += bytes([payload_len])
163
+ elif payload_len <= 65535:
164
+ header += bytes([126]) + payload_len.to_bytes(2, 'big')
165
+ else:
166
+ header += bytes([127]) + payload_len.to_bytes(8, 'big')
167
+
168
+ return header + payload
169
+
170
+ def parse_close_payload(payload):
171
+ """
172
+ Si payload >=2, los primeros 2 bytes son close-code, resto utf-8 reason.
173
+ Devuelve (code:int or None, reason:str or None)
174
+ """
175
+ if not payload:
176
+ return None, None
177
+ if len(payload) >= 2:
178
+ code = int.from_bytes(payload[:2], 'big')
179
+ reason = ''
180
+ if len(payload) > 2:
181
+ try:
182
+ reason = payload[2:].decode('utf-8', errors='ignore')
183
+ except Exception:
184
+ reason = ''
185
+ return code, reason
186
+ return None, None
187
+
188
+ # ============================================================
189
+ # HTTP helpers
190
+ # ============================================================
191
+
192
+ def parse_http_request(conn):
193
+ data = b''
194
+ while b'\r\n\r\n' not in data:
195
+ chunk = conn.recv(1024)
196
+ if not chunk:
197
+ break
198
+ data += chunk
199
+ if len(data) > 65536:
200
+ break
201
+ header_text, _, remainder = data.partition(b'\r\n\r\n')
202
+ lines = header_text.decode('utf-8', errors='ignore').split('\r\n')
203
+ if not lines:
204
+ return None
205
+ request_line = lines[0]
206
+ parts = request_line.split()
207
+ if len(parts) != 3:
208
+ return None
209
+ method, path, version = parts
210
+ if not version.startswith('HTTP/'):
211
+ return None
212
+ headers = {}
213
+ for line in lines[1:]:
214
+ if ':' in line:
215
+ k, v = line.split(':', 1)
216
+ headers[k.strip().lower()] = v.strip()
217
+ return {
218
+ 'method': method,
219
+ 'path': path,
220
+ 'version': version,
221
+ 'headers': headers,
222
+ 'remainder': remainder
223
+ }
224
+
225
+ def send_http_response(conn, status_code=200, reason='OK', headers=None, body=b''):
226
+ if headers is None:
227
+ headers = {}
228
+ status_line = f"HTTP/1.1 {status_code} {reason}\r\n"
229
+ hdrs = ''
230
+ lowermap = {k.lower(): v for k, v in headers.items()}
231
+ if 'content-length' not in lowermap:
232
+ headers['Content-Length'] = str(len(body))
233
+ if 'connection' not in lowermap:
234
+ headers['Connection'] = 'close'
235
+ if 'server' not in lowermap:
236
+ headers['Server'] = 'MinimalWS/1.0'
237
+ for k, v in headers.items():
238
+ hdrs += f"{k}: {v}\r\n"
239
+ resp = status_line + hdrs + "\r\n"
240
+ try:
241
+ conn.sendall(resp.encode('utf-8') + body)
242
+ except Exception as e:
243
+ print(f"[HTTP] Error enviando respuesta {status_code}: {e}")
244
+
245
+ def parse_query_string(qs):
246
+ """
247
+ Parser sencillo de query-string (sin decode de %).
248
+ Ej: "limit=50&foo=bar" -> {"limit": "50", "foo": "bar"}
249
+ """
250
+ params = {}
251
+ if not qs:
252
+ return params
253
+ parts = qs.split('&')
254
+ for part in parts:
255
+ if not part:
256
+ continue
257
+ if '=' in part:
258
+ k, v = part.split('=', 1)
259
+ else:
260
+ k, v = part, ''
261
+ params[k] = v
262
+ return params
263
+
264
+ # ============================================================
265
+ # HTML page (servida en /)
266
+ # ============================================================
267
+
268
+ INDEX_HTML = """<!doctype html>
269
+ <html>
270
+ <head>
271
+ <meta charset="utf-8" />
272
+ <title>Servidor WS avanzado</title>
273
+ <style>
274
+ body{font-family: Arial, sans-serif; max-width:900px; margin:20px;}
275
+ button, input, select { margin:4px; }
276
+ pre { background:#f0f0f0; padding:8px; overflow:auto; }
277
+ fieldset{margin-bottom:10px;}
278
+ legend{font-weight:bold;}
279
+ #messages{max-height:250px; overflow-y:auto; border:1px solid #ccc; padding:4px;}
280
+ #chat_text{width:70%;}
281
+ </style>
282
+ </head>
283
+ <body>
284
+ <h1>Servidor HTTP + WebSocket (avanzado)</h1>
285
+ <p>Pruebas WebSocket + ORM SQLite (mensajes de chat persistidos).</p>
286
+ <p>
287
+ Support PortHound (optional): BTC
288
+ <code id="btc_address">bc1q3lhxpr9yantvefmvhpd2h4lu0ykf3t45zvuve2</code>
289
+ <button id="copy_btc" type="button">Copiar BTC</button>
290
+ </p>
291
+
292
+ <fieldset>
293
+ <legend>Conexión WebSocket</legend>
294
+ <div>
295
+ <label>Plantillas de subprotocolos:
296
+ <select id="subprotos_preset">
297
+ <option value="">-- seleccionar plantilla --</option>
298
+ <option value="chat">chat</option>
299
+ <option value="chat,json">chat,json</option>
300
+ <option value="chat,json,superchat">chat,json,superchat</option>
301
+ </select>
302
+ </label>
303
+ </div>
304
+ <div>
305
+ <label>Subprotocolos deseados (comma-separated):
306
+ <input id="subprotos" value="chat"/>
307
+ </label>
308
+ </div>
309
+
310
+ <button id="connect">Conectar WS</button>
311
+ <button id="disconnect">Desconectar</button>
312
+ <div id="wsstatus">Estado: cerrado</div>
313
+ <div>ID de este cliente (asignado por servidor): <code id="this_client_id">desconocido</code></div>
314
+ </fieldset>
315
+
316
+ <fieldset>
317
+ <legend>Enviar (cliente)</legend>
318
+ <input id="textmsg" placeholder="Texto..." size="60"/>
319
+ <button id="sendtext">Enviar texto</button>
320
+ <button id="sendbinary">Enviar binario (ejemplo 0..9)</button>
321
+ <button id="sendfragmented">Enviar fragmentado (texto largo dividido)</button>
322
+ </fieldset>
323
+
324
+ <fieldset>
325
+ <legend>Peticiones REST al servidor (control desde página)</legend>
326
+ <button id="listclients">Listar clientes (API) + rellenar select</button>
327
+ <button id="server_broadcast">Pedir broadcast de texto por servidor</button>
328
+ <button id="server_ping">Pedir ping desde servidor a todos</button>
329
+ <button id="server_chat_list">Listar mensajes de chat (API)</button>
330
+ <button id="server_chat_clear">Borrar mensajes de chat (API)</button>
331
+ </fieldset>
332
+
333
+ <fieldset>
334
+ <legend>Cerrar conexión desde servidor</legend>
335
+ <div>
336
+ <label>Seleccionar cliente:
337
+ <select id="close_client_select">
338
+ <option value="">-- sin selección (usar campo manual) --</option>
339
+ </select>
340
+ </label>
341
+ </div>
342
+ <div>
343
+ <input id="close_client_id" placeholder="client id (manual / opcional)" size="40"/>
344
+ </div>
345
+ <div>
346
+ <input id="close_code" placeholder="Código (1000)" size="8"/>
347
+ <input id="close_reason" placeholder="Razón" size="20"/>
348
+ <button id="server_close">Pedir cerrar</button>
349
+ </div>
350
+ </fieldset>
351
+
352
+ <h3>Mensajes / Log</h3>
353
+ <ul id="messages"></ul>
354
+
355
+ <hr/>
356
+
357
+ <fieldset>
358
+ <legend>Chat sencillo (sobre este servidor, persistido en SQLite)</legend>
359
+ <div>
360
+ <label>Alias:
361
+ <input id="chat_alias" placeholder="tu nombre o nick" size="20"/>
362
+ </label>
363
+ <button id="chat_save_alias">Guardar alias</button>
364
+ </div>
365
+ <div>
366
+ <label>Modo de envío:
367
+ <select id="chat_mode">
368
+ <option value="local">Solo a este WebSocket (echo servidor)</option>
369
+ <option value="broadcast">Broadcast a todos (API /api/ws/broadcast)</option>
370
+ <option value="ping">Ping (API /api/ws/ping)</option>
371
+ </select>
372
+ </label>
373
+ </div>
374
+ <div>
375
+ <input id="chat_text" placeholder="Escribe un mensaje de chat..." />
376
+ <button id="chat_send">Enviar</button>
377
+ <button id="chat_clear">Limpiar log</button>
378
+ </div>
379
+ <p style="font-size:12px;color:#666;">
380
+ Los mensajes que parezcan "[alias] texto" se guardan en SQLite en la tabla "chat_messages".
381
+ Puedes verlos con <code>GET /api/chat/messages?limit=20</code>.
382
+ </p>
383
+ </fieldset>
384
+
385
+ <script>
386
+ let ws = null;
387
+
388
+ function addMsg(s){
389
+ const ul = document.getElementById('messages');
390
+ const li = document.createElement('li');
391
+ const ts = new Date().toISOString();
392
+ li.textContent = '[' + ts + '] ' + s;
393
+ ul.appendChild(li);
394
+ ul.scrollTop = ul.scrollHeight;
395
+ }
396
+
397
+ document.getElementById('copy_btc').onclick = function(){
398
+ const el = document.getElementById('btc_address');
399
+ const value = el ? String(el.textContent || '').trim() : '';
400
+ if(!value){
401
+ return;
402
+ }
403
+ if(navigator && navigator.clipboard && navigator.clipboard.writeText){
404
+ navigator.clipboard.writeText(value)
405
+ .then(() => addMsg('[BTC] Dirección copiada al portapapeles.'))
406
+ .catch(() => addMsg('[BTC] No se pudo copiar automáticamente.'));
407
+ return;
408
+ }
409
+ addMsg('[BTC] Copia manual: ' + value);
410
+ };
411
+
412
+ function refreshClientsSelect(list){
413
+ const sel = document.getElementById('close_client_select');
414
+ if(!sel) return;
415
+ sel.innerHTML = '';
416
+ const opt0 = document.createElement('option');
417
+ opt0.value = '';
418
+ opt0.textContent = '-- sin selección (usar campo manual) --';
419
+ sel.appendChild(opt0);
420
+
421
+ if(!Array.isArray(list)) return;
422
+ list.forEach(c => {
423
+ const opt = document.createElement('option');
424
+ opt.value = c.id;
425
+ const proto = c.subprotocol ? (', proto=' + c.subprotocol) : '';
426
+ opt.textContent = c.id + ' [' + c.addr + proto + ']';
427
+ sel.appendChild(opt);
428
+ });
429
+ }
430
+
431
+ function getChatAlias(){
432
+ const el = document.getElementById('chat_alias');
433
+ let alias = el ? el.value.trim() : '';
434
+ if(!alias) alias = 'anon';
435
+ return alias;
436
+ }
437
+
438
+ (function initAlias(){
439
+ try{
440
+ const stored = localStorage.getItem('ws_chat_alias');
441
+ if(stored){
442
+ const el = document.getElementById('chat_alias');
443
+ if(el) el.value = stored;
444
+ }
445
+ }catch(e){}
446
+ })();
447
+
448
+ document.getElementById('subprotos_preset').onchange = function(){
449
+ const val = this.value;
450
+ if(val){
451
+ document.getElementById('subprotos').value = val;
452
+ }
453
+ };
454
+
455
+ document.getElementById('connect').onclick = function(){
456
+ if(ws && ws.readyState === WebSocket.OPEN) return;
457
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
458
+ const host = location.host;
459
+ const sublist = document.getElementById('subprotos').value.trim();
460
+ const subprotos = sublist ? sublist.split(',').map(s=>s.trim()).filter(Boolean) : undefined;
461
+ const wsurl = protocol + '//' + host + '/ws/';
462
+ try {
463
+ ws = subprotos && subprotos.length ? new WebSocket(wsurl, subprotos) : new WebSocket(wsurl);
464
+ } catch(e) {
465
+ addMsg('Error creando WebSocket: '+e);
466
+ return;
467
+ }
468
+ ws.binaryType = 'arraybuffer';
469
+ ws.onopen = () => {
470
+ document.getElementById('wsstatus').innerText = 'Estado: abierto';
471
+ addMsg('Conectado (protocol='+ws.protocol+')');
472
+ };
473
+ ws.onmessage = (ev) => {
474
+ if(ev.data instanceof ArrayBuffer){
475
+ const a = new Uint8Array(ev.data);
476
+ addMsg('Recibido BINARIO len='+a.length+' bytes: '+Array.from(a).slice(0,20).join(','));
477
+ } else {
478
+ let prefix = 'Recibido: ';
479
+ let txt = ev.data;
480
+ try{
481
+ const jd = JSON.parse(txt);
482
+ if(jd && jd.type === 'welcome' && jd.client_id){
483
+ document.getElementById('this_client_id').textContent = jd.client_id;
484
+ prefix = 'Recibido (welcome JSON): ';
485
+ }
486
+ }catch(e){}
487
+ addMsg(prefix + txt);
488
+ }
489
+ };
490
+ ws.onclose = (e) => {
491
+ document.getElementById('wsstatus').innerText = 'Estado: cerrado';
492
+ addMsg('Cerrado code='+e.code+' reason='+e.reason);
493
+ };
494
+ ws.onerror = (e) => { addMsg('Error WS: ' + e); };
495
+ };
496
+
497
+ document.getElementById('disconnect').onclick = function(){
498
+ if(ws) ws.close();
499
+ };
500
+
501
+ document.getElementById('sendtext').onclick = function(){
502
+ if(!ws || ws.readyState !== WebSocket.OPEN){
503
+ addMsg('WS no conectado');
504
+ return;
505
+ }
506
+ const m = document.getElementById('textmsg').value || ('Hola desde cliente @ '+new Date().toISOString());
507
+ ws.send(m);
508
+ addMsg('Enviado texto: '+m);
509
+ };
510
+
511
+ document.getElementById('sendbinary').onclick = function(){
512
+ if(!ws || ws.readyState !== WebSocket.OPEN){
513
+ addMsg('WS no conectado');
514
+ return;
515
+ }
516
+ const arr = new Uint8Array(10);
517
+ for(let i=0;i<10;i++) arr[i]=i;
518
+ ws.send(arr.buffer);
519
+ addMsg('Enviado binario 10 bytes');
520
+ };
521
+
522
+ document.getElementById('sendfragmented').onclick = function(){
523
+ if(!ws || ws.readyState !== WebSocket.OPEN){
524
+ addMsg('WS no conectado');
525
+ return;
526
+ }
527
+ const parts = ['parte1-','parte2-','parte3-','(fin)'];
528
+ for(let p of parts){
529
+ ws.send(p);
530
+ }
531
+ addMsg('Enviado (simulado) fragmentado como múltiples mensajes consecutivos');
532
+ };
533
+
534
+ async function callApi(path, method='GET', body=null, onOk){
535
+ const opts = { method, headers: {} };
536
+ if(body){
537
+ opts.body = JSON.stringify(body);
538
+ opts.headers['Content-Type']='application/json';
539
+ }
540
+ const r = await fetch(path, opts);
541
+ const t = await r.text();
542
+ let parsed = null;
543
+ try{ parsed = JSON.parse(t); }catch(e){}
544
+ if(r.ok && typeof onOk === 'function'){
545
+ try{ onOk(parsed, t, r); }catch(e){}
546
+ }
547
+ addMsg('/api -> ' + r.status + ' ' + r.statusText + ' : ' + t);
548
+ return t;
549
+ }
550
+
551
+ document.getElementById('listclients').onclick = () =>
552
+ callApi('/api/ws/clients', 'GET', null, (jsonList) => {
553
+ refreshClientsSelect(jsonList);
554
+ });
555
+
556
+ document.getElementById('server_broadcast').onclick = () => {
557
+ const text = prompt('Texto para broadcast por servidor:', 'Broadcast desde servidor @ '+new Date().toISOString());
558
+ if(text) callApi('/api/ws/broadcast', 'POST', {type:'text', message:text});
559
+ };
560
+
561
+ document.getElementById('server_ping').onclick = () =>
562
+ callApi('/api/ws/ping', 'POST', {});
563
+
564
+ document.getElementById('server_close').onclick = () => {
565
+ const sel = document.getElementById('close_client_select');
566
+ let id = sel && sel.value ? sel.value : null;
567
+ if(!id){
568
+ const manual = document.getElementById('close_client_id').value.trim();
569
+ if(manual) id = manual;
570
+ }
571
+ const code = parseInt(document.getElementById('close_code').value) || 1000;
572
+ const reason = document.getElementById('close_reason').value || '';
573
+ callApi('/api/ws/close', 'POST', {client_id: id, code: code, reason: reason});
574
+ };
575
+
576
+ document.getElementById('server_chat_list').onclick = () =>
577
+ callApi('/api/chat/messages?limit=20', 'GET', null);
578
+
579
+ document.getElementById('server_chat_clear').onclick = () =>
580
+ callApi('/api/chat/clear', 'POST', {});
581
+
582
+ // ----- Chat UI -----
583
+ document.getElementById('chat_save_alias').onclick = function(){
584
+ const alias = getChatAlias();
585
+ try{
586
+ localStorage.setItem('ws_chat_alias', alias);
587
+ }catch(e){}
588
+ addMsg('[CHAT] Alias establecido: ' + alias);
589
+ };
590
+
591
+ document.getElementById('chat_clear').onclick = function(){
592
+ const ul = document.getElementById('messages');
593
+ ul.innerHTML = '';
594
+ };
595
+
596
+ document.getElementById('chat_send').onclick = function(){
597
+ const txtEl = document.getElementById('chat_text');
598
+ const raw = (txtEl.value || '').trim();
599
+ if(!raw){
600
+ addMsg('[CHAT] Mensaje vacío, no se envía.');
601
+ return;
602
+ }
603
+ const alias = getChatAlias();
604
+ const modeSel = document.getElementById('chat_mode');
605
+ const mode = modeSel ? modeSel.value : 'local';
606
+ const full = '[' + alias + '] ' + raw;
607
+
608
+ if(mode === 'local'){
609
+ if(!ws || ws.readyState !== WebSocket.OPEN){
610
+ addMsg('[CHAT] WS no conectado (modo local).');
611
+ return;
612
+ }
613
+ ws.send(full);
614
+ addMsg('[CHAT->WS] ' + full);
615
+ } else if(mode === 'broadcast'){
616
+ callApi('/api/ws/broadcast', 'POST', {type:'text', message: full});
617
+ addMsg('[CHAT->API broadcast] ' + full);
618
+ } else if(mode === 'ping'){
619
+ callApi('/api/ws/ping', 'POST', {payload: full});
620
+ addMsg('[CHAT->API ping] ' + full);
621
+ }
622
+
623
+ txtEl.value = '';
624
+ txtEl.focus();
625
+ };
626
+
627
+ document.getElementById('chat_text').addEventListener('keydown', function(ev){
628
+ if(ev.key === 'Enter'){
629
+ ev.preventDefault();
630
+ document.getElementById('chat_send').click();
631
+ }
632
+ });
633
+ </script>
634
+ </body>
635
+ </html>
636
+ """.encode('utf-8')
637
+
638
+ # ============================================================
639
+ # ORM SQLite (solo sqlite3 + threading)
640
+ # ============================================================
641
+
642
+ class Field:
643
+ def __init__(self, column_type, primary_key=False, unique=False,
644
+ null=True, default=None, index=False):
645
+ self.column_type = column_type
646
+ self.primary_key = primary_key
647
+ self.unique = unique
648
+ self.null = null
649
+ self.default = default
650
+ self.index = index
651
+ self.name = None # se rellena en el metaclass
652
+
653
+ def _sql_literal(self, value):
654
+ if value is None:
655
+ return "NULL"
656
+ if isinstance(value, bool):
657
+ return "1" if value else "0"
658
+ if isinstance(value, (int, float)):
659
+ return str(value)
660
+ s = str(value).replace("'", "''")
661
+ return "'" + s + "'"
662
+
663
+ def ddl_fragment(self):
664
+ parts = [self.name, self.column_type]
665
+ if self.primary_key:
666
+ parts.append("PRIMARY KEY")
667
+ if self.unique:
668
+ parts.append("UNIQUE")
669
+ if not self.null and not self.primary_key:
670
+ parts.append("NOT NULL")
671
+ if self.default is not None and not self.primary_key:
672
+ parts.append("DEFAULT")
673
+ parts.append(self._sql_literal(self.default))
674
+ return " ".join(parts)
675
+
676
+ class IntegerField(Field):
677
+ def __init__(self, primary_key=False, unique=False, null=True,
678
+ default=None, index=False, auto_increment=False):
679
+ super().__init__("INTEGER", primary_key=primary_key, unique=unique,
680
+ null=null, default=default, index=index)
681
+ self.auto_increment = auto_increment
682
+
683
+ def ddl_fragment(self):
684
+ parts = [self.name, self.column_type]
685
+ if self.primary_key:
686
+ parts.append("PRIMARY KEY")
687
+ if self.auto_increment and self.primary_key:
688
+ parts.append("AUTOINCREMENT")
689
+ if self.unique and not self.primary_key:
690
+ parts.append("UNIQUE")
691
+ if not self.null and not self.primary_key:
692
+ parts.append("NOT NULL")
693
+ if self.default is not None and not self.primary_key:
694
+ parts.append("DEFAULT")
695
+ parts.append(self._sql_literal(self.default))
696
+ return " ".join(parts)
697
+
698
+ class TextField(Field):
699
+ def __init__(self, primary_key=False, unique=False, null=True,
700
+ default=None, index=False):
701
+ super().__init__("TEXT", primary_key=primary_key, unique=unique,
702
+ null=null, default=default, index=index)
703
+
704
+ class RealField(Field):
705
+ def __init__(self, primary_key=False, unique=False, null=True,
706
+ default=None, index=False):
707
+ super().__init__("REAL", primary_key=primary_key, unique=unique,
708
+ null=null, default=default, index=index)
709
+
710
+ class BlobField(Field):
711
+ def __init__(self, primary_key=False, unique=False, null=True,
712
+ default=None, index=False):
713
+ super().__init__("BLOB", primary_key=primary_key, unique=unique,
714
+ null=null, default=default, index=index)
715
+
716
+ class BooleanField(IntegerField):
717
+ def __init__(self, primary_key=False, unique=False, null=True,
718
+ default=None, index=False):
719
+ super().__init__(primary_key=primary_key, unique=unique,
720
+ null=null, default=default, index=index)
721
+
722
+ class ModelMeta(type):
723
+ def __new__(mcls, name, bases, attrs):
724
+ if name == "Model":
725
+ return super().__new__(mcls, name, bases, attrs)
726
+
727
+ fields = {}
728
+ for key, value in list(attrs.items()):
729
+ if isinstance(value, Field):
730
+ value.name = key
731
+ fields[key] = value
732
+
733
+ tablename = attrs.get("__tablename__") or name.lower()
734
+ pk_name = None
735
+ for fname, field in fields.items():
736
+ if field.primary_key:
737
+ pk_name = fname
738
+ break
739
+
740
+ attrs["_meta"] = {
741
+ "table": tablename,
742
+ "fields": fields,
743
+ "pk_name": pk_name,
744
+ }
745
+
746
+ return super().__new__(mcls, name, bases, attrs)
747
+
748
+ class Database:
749
+ def __init__(self, path=":memory:", shared_cache=False,
750
+ timeout=10.0, pragmas=None):
751
+ """
752
+ path=":memory:" -> BD en memoria privada
753
+ shared_cache=True -> usa "file::memory:?cache=shared" (uri=True)
754
+ timeout=10.0 -> busy timeout
755
+ pragmas=dict() -> PRAGMAs extra
756
+ """
757
+ self._lock = threading.RLock()
758
+ self._tx_depth = threading.local()
759
+
760
+ if shared_cache:
761
+ if path == ":memory:":
762
+ dsn = "file::memory:?cache=shared"
763
+ else:
764
+ dsn = path
765
+ self._conn = sqlite3.connect(
766
+ dsn,
767
+ check_same_thread=False,
768
+ timeout=timeout,
769
+ uri=True,
770
+ )
771
+ else:
772
+ self._conn = sqlite3.connect(
773
+ path,
774
+ check_same_thread=False,
775
+ timeout=timeout,
776
+ )
777
+
778
+ self._conn.row_factory = sqlite3.Row
779
+
780
+ base_pragmas = {
781
+ "journal_mode": "WAL",
782
+ "synchronous": "NORMAL",
783
+ "foreign_keys": 1,
784
+ "busy_timeout": int(timeout * 1000),
785
+ }
786
+ if pragmas:
787
+ base_pragmas.update(pragmas)
788
+
789
+ for k, v in base_pragmas.items():
790
+ self.set_pragma(k, v)
791
+
792
+ def set_pragma(self, name, value):
793
+ sql = "PRAGMA %s=%s" % (name, value)
794
+ with self._lock:
795
+ print("[DB]", sql)
796
+ self._conn.execute(sql)
797
+
798
+ def get_pragma(self, name):
799
+ sql = "PRAGMA %s" % name
800
+ with self._lock:
801
+ print("[DB]", sql)
802
+ cur = self._conn.execute(sql)
803
+ row = cur.fetchone()
804
+ if row is None:
805
+ return None
806
+ return row[0]
807
+
808
+ def _get_tx_depth(self):
809
+ return getattr(self._tx_depth, "value", 0)
810
+
811
+ def _set_tx_depth(self, v):
812
+ self._tx_depth.value = v
813
+
814
+ def in_transaction(self):
815
+ return self._get_tx_depth() > 0
816
+
817
+ def execute(self, sql, params=None):
818
+ if params is None:
819
+ params = ()
820
+ sql_trim = sql.strip()
821
+ first = sql_trim.split()[0].upper() if sql_trim else ""
822
+ is_write = first in (
823
+ "INSERT", "UPDATE", "DELETE", "REPLACE",
824
+ "CREATE", "DROP", "ALTER",
825
+ )
826
+ with self._lock:
827
+ print("[DB] SQL:", sql_trim, "params=", params)
828
+ cur = self._conn.execute(sql, params)
829
+ if is_write and not self.in_transaction():
830
+ self._conn.commit()
831
+ return cur
832
+
833
+ def executemany(self, sql, seq_of_params):
834
+ sql_trim = sql.strip()
835
+ first = sql_trim.split()[0].upper() if sql_trim else ""
836
+ is_write = first in ("INSERT", "UPDATE", "DELETE", "REPLACE")
837
+ with self._lock:
838
+ count = 0
839
+ for p in seq_of_params:
840
+ print("[DB] SQL many:", sql_trim, "params=", p)
841
+ self._conn.execute(sql, p)
842
+ count += 1
843
+ if is_write and not self.in_transaction():
844
+ self._conn.commit()
845
+ return count
846
+
847
+ def transaction(self):
848
+ return Transaction(self)
849
+
850
+ class Transaction:
851
+ def __init__(self, db):
852
+ self.db = db
853
+ self._sp_name = None
854
+ self._entered = False
855
+
856
+ def __enter__(self):
857
+ depth = self.db._get_tx_depth()
858
+ if depth == 0:
859
+ with self.db._lock:
860
+ print("[DB] BEGIN")
861
+ self.db._conn.execute("BEGIN")
862
+ self.db._set_tx_depth(1)
863
+ else:
864
+ self._sp_name = "sp_%d" % depth
865
+ with self.db._lock:
866
+ print("[DB] SAVEPOINT", self._sp_name)
867
+ self.db._conn.execute("SAVEPOINT %s" % self._sp_name)
868
+ self.db._set_tx_depth(depth + 1)
869
+ self._entered = True
870
+ return self
871
+
872
+ def __exit__(self, exc_type, exc, tb):
873
+ if not self._entered:
874
+ return False
875
+
876
+ depth = self.db._get_tx_depth()
877
+ try:
878
+ if exc_type is not None:
879
+ if self._sp_name is None:
880
+ with self.db._lock:
881
+ print("[DB] ROLLBACK")
882
+ self.db._conn.rollback()
883
+ self.db._set_tx_depth(0)
884
+ else:
885
+ with self.db._lock:
886
+ print("[DB] ROLLBACK TO SAVEPOINT", self._sp_name)
887
+ self.db._conn.execute("ROLLBACK TO SAVEPOINT %s" % self._sp_name)
888
+ print("[DB] RELEASE SAVEPOINT", self._sp_name)
889
+ self.db._conn.execute("RELEASE SAVEPOINT %s" % self._sp_name)
890
+ self.db._set_tx_depth(depth - 1)
891
+ else:
892
+ if self._sp_name is None:
893
+ with self.db._lock:
894
+ print("[DB] COMMIT")
895
+ self.db._conn.commit()
896
+ self.db._set_tx_depth(0)
897
+ else:
898
+ with self.db._lock:
899
+ print("[DB] RELEASE SAVEPOINT", self._sp_name)
900
+ self.db._conn.execute("RELEASE SAVEPOINT %s" % self._sp_name)
901
+ self.db._set_tx_depth(depth - 1)
902
+ finally:
903
+ self._entered = False
904
+ return False
905
+
906
+ class QuerySet:
907
+ def __init__(self, db, model, where=None, params=None,
908
+ order_by=None, limit=None, offset=None):
909
+ self.db = db
910
+ self.model = model
911
+ self.where = where
912
+ self.params = params or []
913
+ self.order_by_clause = order_by
914
+ self.limit_val = limit
915
+ self.offset_val = offset
916
+
917
+ def _clone(self, **overrides):
918
+ kwargs = dict(
919
+ db=self.db,
920
+ model=self.model,
921
+ where=self.where,
922
+ params=list(self.params),
923
+ order_by=self.order_by_clause,
924
+ limit=self.limit_val,
925
+ offset=self.offset_val,
926
+ )
927
+ for k, v in overrides.items():
928
+ kwargs[k] = v
929
+ return QuerySet(**kwargs)
930
+
931
+ def where_raw(self, clause, *params):
932
+ if self.where:
933
+ new_where = "(" + self.where + ") AND (" + clause + ")"
934
+ new_params = list(self.params) + list(params)
935
+ else:
936
+ new_where = clause
937
+ new_params = list(params)
938
+ return self._clone(where=new_where, params=new_params)
939
+
940
+ def filter(self, **kwargs):
941
+ clauses = []
942
+ params = []
943
+ for key, value in kwargs.items():
944
+ if "__" in key:
945
+ field_name, op = key.split("__", 1)
946
+ else:
947
+ field_name, op = key, "eq"
948
+
949
+ column = field_name
950
+ if op == "eq":
951
+ clauses.append(column + " = ?")
952
+ params.append(value)
953
+ elif op == "gt":
954
+ clauses.append(column + " > ?")
955
+ params.append(value)
956
+ elif op == "lt":
957
+ clauses.append(column + " < ?")
958
+ params.append(value)
959
+ elif op == "gte":
960
+ clauses.append(column + " >= ?")
961
+ params.append(value)
962
+ elif op == "lte":
963
+ clauses.append(column + " <= ?")
964
+ params.append(value)
965
+ elif op == "ne":
966
+ clauses.append(column + " != ?")
967
+ params.append(value)
968
+ elif op == "like":
969
+ clauses.append(column + " LIKE ?")
970
+ params.append(value)
971
+ elif op == "in":
972
+ if not value:
973
+ clauses.append("1=0")
974
+ else:
975
+ placeholders = ",".join("?" for _ in value)
976
+ clauses.append(column + " IN (" + placeholders + ")")
977
+ for v in value:
978
+ params.append(v)
979
+ else:
980
+ raise ValueError("Operador no soportado: " + op)
981
+
982
+ if clauses:
983
+ clause = " AND ".join(clauses)
984
+ return self.where_raw(clause, *params)
985
+ else:
986
+ return self
987
+
988
+ def order_by(self, clause):
989
+ return self._clone(order_by=clause)
990
+
991
+ def limit(self, n, offset=None):
992
+ return self._clone(limit=n, offset=offset if offset is not None else self.offset_val)
993
+
994
+ def _build_select(self):
995
+ fields = list(self.model._meta["fields"].keys())
996
+ columns_sql = ", ".join(fields)
997
+ table = self.model._meta["table"]
998
+ sql = "SELECT " + columns_sql + " FROM " + table
999
+ params = list(self.params)
1000
+ if self.where:
1001
+ sql += " WHERE " + self.where
1002
+ if self.order_by_clause:
1003
+ sql += " ORDER BY " + self.order_by_clause
1004
+ if self.limit_val is not None:
1005
+ sql += " LIMIT ?"
1006
+ params.append(self.limit_val)
1007
+ if self.offset_val is not None:
1008
+ sql += " OFFSET ?"
1009
+ params.append(self.offset_val)
1010
+ return sql, params
1011
+
1012
+ def all(self):
1013
+ sql, params = self._build_select()
1014
+ cur = self.db.execute(sql, params)
1015
+ rows = cur.fetchall()
1016
+ objs = []
1017
+ for row in rows:
1018
+ objs.append(self.model.from_row(row))
1019
+ return objs
1020
+
1021
+ def first(self):
1022
+ qs = self.limit(1)
1023
+ sql, params = qs._build_select()
1024
+ cur = self.db.execute(sql, params)
1025
+ row = cur.fetchone()
1026
+ if not row:
1027
+ return None
1028
+ return self.model.from_row(row)
1029
+
1030
+ def get(self, **kwargs):
1031
+ qs = self.filter(**kwargs).limit(2)
1032
+ sql, params = qs._build_select()
1033
+ cur = self.db.execute(sql, params)
1034
+ rows = cur.fetchall()
1035
+ if not rows:
1036
+ raise LookupError("No se encontró ningún registro")
1037
+ if len(rows) > 1:
1038
+ raise LookupError("Se encontraron múltiples registros para .get()")
1039
+ return self.model.from_row(rows[0])
1040
+
1041
+ def count(self):
1042
+ table = self.model._meta["table"]
1043
+ sql = "SELECT COUNT(*) AS c FROM " + table
1044
+ params = list(self.params)
1045
+ if self.where:
1046
+ sql += " WHERE " + self.where
1047
+ cur = self.db.execute(sql, params)
1048
+ row = cur.fetchone()
1049
+ return row["c"]
1050
+
1051
+ def delete(self):
1052
+ table = self.model._meta["table"]
1053
+ sql = "DELETE FROM " + table
1054
+ params = list(self.params)
1055
+ if self.where:
1056
+ sql += " WHERE " + self.where
1057
+ cur = self.db.execute(sql, params)
1058
+ return cur.rowcount
1059
+
1060
+ def update(self, **kwargs):
1061
+ if not kwargs:
1062
+ return 0
1063
+ table = self.model._meta["table"]
1064
+ set_clauses = []
1065
+ params = []
1066
+ for k, v in kwargs.items():
1067
+ set_clauses.append(k + " = ?")
1068
+ params.append(v)
1069
+ sql = "UPDATE " + table + " SET " + ", ".join(set_clauses)
1070
+ if self.where:
1071
+ sql += " WHERE " + self.where
1072
+ params.extend(self.params)
1073
+ cur = self.db.execute(sql, params)
1074
+ return cur.rowcount
1075
+
1076
+ def create(self, **kwargs):
1077
+ obj = self.model(**kwargs)
1078
+ obj.save(self.db)
1079
+ return obj
1080
+
1081
+ class Model(metaclass=ModelMeta):
1082
+ __tablename__ = None
1083
+
1084
+ def __init__(self, **kwargs):
1085
+ fields = self._meta["fields"]
1086
+ for name, field in fields.items():
1087
+ if name in kwargs:
1088
+ value = kwargs[name]
1089
+ else:
1090
+ if callable(field.default):
1091
+ value = field.default()
1092
+ else:
1093
+ value = field.default
1094
+ setattr(self, name, value)
1095
+
1096
+ @classmethod
1097
+ def create_table(cls, db, if_not_exists=True):
1098
+ fields = cls._meta["fields"]
1099
+ table = cls._meta["table"]
1100
+ if not fields:
1101
+ raise RuntimeError("Modelo sin campos: " + cls.__name__)
1102
+
1103
+ column_ddls = []
1104
+ index_ddls = []
1105
+ for name, field in fields.items():
1106
+ field.name = name
1107
+ column_ddls.append(field.ddl_fragment())
1108
+ if field.index and not field.primary_key:
1109
+ idx_name = "idx_%s_%s" % (table, name)
1110
+ unique = "UNIQUE " if field.unique else ""
1111
+ idx_sql = "CREATE " + unique + "INDEX IF NOT EXISTS " + idx_name
1112
+ idx_sql += " ON " + table + " (" + name + ");"
1113
+ index_ddls.append(idx_sql)
1114
+
1115
+ ine = "IF NOT EXISTS " if if_not_exists else ""
1116
+ sql = "CREATE TABLE " + ine + table + " (\n "
1117
+ sql += ",\n ".join(column_ddls) + "\n);"
1118
+ db.execute(sql)
1119
+ for idx_sql in index_ddls:
1120
+ db.execute(idx_sql)
1121
+
1122
+ @classmethod
1123
+ def drop_table(cls, db, if_exists=True):
1124
+ table = cls._meta["table"]
1125
+ ie = "IF EXISTS " if if_exists else ""
1126
+ sql = "DROP TABLE " + ie + table + ";"
1127
+ db.execute(sql)
1128
+
1129
+ @classmethod
1130
+ def objects(cls, db):
1131
+ return QuerySet(db, cls)
1132
+
1133
+ @classmethod
1134
+ def from_row(cls, row):
1135
+ if row is None:
1136
+ return None
1137
+ data = {}
1138
+ for name in cls._meta["fields"].keys():
1139
+ data[name] = row[name]
1140
+ return cls(**data)
1141
+
1142
+ @classmethod
1143
+ def raw(cls, db, sql, params=None):
1144
+ if params is None:
1145
+ params = ()
1146
+ cur = db.execute(sql, params)
1147
+ rows = cur.fetchall()
1148
+ objs = []
1149
+ for row in rows:
1150
+ data = {}
1151
+ for name in cls._meta["fields"].keys():
1152
+ if name in row.keys():
1153
+ data[name] = row[name]
1154
+ else:
1155
+ data[name] = None
1156
+ objs.append(cls(**data))
1157
+ return objs
1158
+
1159
+ def pk_value(self):
1160
+ pk_name = self._meta["pk_name"]
1161
+ if pk_name is None:
1162
+ return None
1163
+ return getattr(self, pk_name, None)
1164
+
1165
+ def save(self, db):
1166
+ fields = self._meta["fields"]
1167
+ table = self._meta["table"]
1168
+ pk_name = self._meta["pk_name"]
1169
+ pk_val = None
1170
+ if pk_name:
1171
+ pk_val = getattr(self, pk_name, None)
1172
+
1173
+ if pk_name and pk_val is not None:
1174
+ cols = []
1175
+ params = []
1176
+ for name, field in fields.items():
1177
+ if name == pk_name:
1178
+ continue
1179
+ cols.append(name + "=?")
1180
+ params.append(getattr(self, name))
1181
+ params.append(pk_val)
1182
+ sql = "UPDATE " + table + " SET " + ", ".join(cols)
1183
+ sql += " WHERE " + pk_name + "=?"
1184
+ cur = db.execute(sql, params)
1185
+ return cur.rowcount
1186
+ else:
1187
+ cols = []
1188
+ placeholders = []
1189
+ params = []
1190
+ for name, field in fields.items():
1191
+ if field.primary_key and isinstance(field, IntegerField):
1192
+ continue
1193
+ cols.append(name)
1194
+ placeholders.append("?")
1195
+ params.append(getattr(self, name))
1196
+ sql = "INSERT INTO " + table + " (" + ", ".join(cols) + ")"
1197
+ sql += " VALUES (" + ", ".join(placeholders) + ")"
1198
+ cur = db.execute(sql, params)
1199
+ if pk_name and isinstance(fields[pk_name], IntegerField):
1200
+ new_id = cur.lastrowid
1201
+ setattr(self, pk_name, new_id)
1202
+ return 1
1203
+
1204
+ def delete(self, db):
1205
+ table = self._meta["table"]
1206
+ pk_name = self._meta["pk_name"]
1207
+ if not pk_name:
1208
+ raise RuntimeError("Modelo sin clave primaria, delete() no soportado")
1209
+ pk_val = getattr(self, pk_name, None)
1210
+ if pk_val is None:
1211
+ return 0
1212
+ sql = "DELETE FROM " + table + " WHERE " + pk_name + "=?"
1213
+ cur = db.execute(sql, (pk_val,))
1214
+ return cur.rowcount
1215
+
1216
+ def to_dict(self):
1217
+ data = {}
1218
+ for name in self._meta["fields"].keys():
1219
+ data[name] = getattr(self, name)
1220
+ return data
1221
+
1222
+ # ============================================================
1223
+ # Modelo ChatMessage (ejemplo de uso del ORM)
1224
+ # ============================================================
1225
+
1226
+ class ChatMessage(Model):
1227
+ __tablename__ = "chat_messages"
1228
+ id = IntegerField(primary_key=True, auto_increment=True)
1229
+ client_id = TextField(index=True, null=False)
1230
+ alias = TextField(null=False)
1231
+ message = TextField(null=False)
1232
+ created_at = IntegerField(null=False, index=True)
1233
+
1234
+ def parse_chat_line(text):
1235
+ """
1236
+ Intenta parsear "[alias] mensaje" y devuelve (alias, mensaje).
1237
+ Si no coincide el patrón, alias=None y mensaje=text.
1238
+ """
1239
+ if text.startswith('['):
1240
+ idx = text.find(']')
1241
+ if idx > 1:
1242
+ alias = text[1:idx].strip()
1243
+ msg = text[idx+1:].lstrip()
1244
+ return alias or 'anon', msg
1245
+ return None, text
1246
+
1247
+ # ============================================================
1248
+ # ClientRegistry (gestiona clientes WS de forma thread-safe)
1249
+ # ============================================================
1250
+
1251
+ class ClientRegistry:
1252
+ def __init__(self):
1253
+ self._clients = {} # client_id -> dict {sock, addr, thread, subprotocol, created}
1254
+ self._lock = threading.Lock()
1255
+
1256
+ def register_client(self, client_id, sock, addr, thread, subprotocol):
1257
+ with self._lock:
1258
+ self._clients[client_id] = {
1259
+ 'sock': sock,
1260
+ 'addr': addr,
1261
+ 'thread': thread,
1262
+ 'subprotocol': subprotocol or '',
1263
+ 'created': time.time()
1264
+ }
1265
+ try:
1266
+ print(f"[WS] Registrado cliente {client_id} desde {addr} (subprotocol={subprotocol or ''})")
1267
+ except Exception:
1268
+ pass
1269
+
1270
+ def unregister_client(self, client_id):
1271
+ with self._lock:
1272
+ info = self._clients.pop(client_id, None)
1273
+ if info:
1274
+ try:
1275
+ info['sock'].close()
1276
+ except Exception:
1277
+ pass
1278
+ try:
1279
+ print(f"[WS] Cliente {client_id} desconectado")
1280
+ except Exception:
1281
+ pass
1282
+
1283
+ def list_clients_info(self):
1284
+ with self._lock:
1285
+ out = []
1286
+ for cid, info in self._clients.items():
1287
+ out.append({
1288
+ 'id': cid,
1289
+ 'addr': f"{info['addr'][0]}:{info['addr'][1]}",
1290
+ 'subprotocol': info.get('subprotocol',''),
1291
+ 'created': info.get('created',0)
1292
+ })
1293
+ print(f"[WS] list_clients_info -> {len(out)} clientes")
1294
+ return out
1295
+
1296
+ def send_to_client(self, client_id, opcode, payload=b''):
1297
+ with self._lock:
1298
+ info = self._clients.get(client_id)
1299
+ if not info:
1300
+ print(f"[WS] send_to_client: cliente {client_id} no encontrado")
1301
+ return False, "no such client"
1302
+ sock = info['sock']
1303
+ try:
1304
+ sock.sendall(make_ws_frame_bytes(opcode, payload))
1305
+ print(f"[WS] send_to_client: cid={client_id} opcode={opcode} len={len(payload)}")
1306
+ return True, "sent"
1307
+ except Exception as e:
1308
+ print(f"[WS] Error en send_to_client {client_id}: {e}")
1309
+ return False, str(e)
1310
+
1311
+ def broadcast(self, opcode, payload=b''):
1312
+ failed = []
1313
+ with self._lock:
1314
+ items = list(self._clients.items())
1315
+ print(f"[WS] broadcast: opcode={opcode} len={len(payload)} a {len(items)} clientes")
1316
+ for cid, info in items:
1317
+ try:
1318
+ info['sock'].sendall(make_ws_frame_bytes(opcode, payload))
1319
+ print(f"[WS] broadcast -> cid={cid} OK")
1320
+ except Exception as e:
1321
+ print(f"[WS] broadcast -> cid={cid} ERROR: {e}")
1322
+ failed.append((cid, str(e)))
1323
+ return failed
1324
+
1325
+ # ============================================================
1326
+ # ConnectionThread (maneja una conexión TCP)
1327
+ # ============================================================
1328
+
1329
+ class ConnectionThread(threading.Thread):
1330
+ def __init__(self, conn, addr, registry, db):
1331
+ super().__init__(daemon=True)
1332
+ self.conn = conn
1333
+ self.addr = addr
1334
+ self.registry = registry
1335
+ self.db = db
1336
+
1337
+ def run(self):
1338
+ print(f"[HTTP] Nueva conexión desde {self.addr}")
1339
+ try:
1340
+ req = parse_http_request(self.conn)
1341
+ if not req:
1342
+ print(f"[HTTP] Petición vacía/incorrecta desde {self.addr}, cerrando.")
1343
+ self.conn.close()
1344
+ return
1345
+
1346
+ raw_path = req['path']
1347
+ method = req['method'].upper()
1348
+ headers = req['headers']
1349
+ print(f"[HTTP] {self.addr} {method} {raw_path}")
1350
+ path, _, query = raw_path.partition('?')
1351
+ req['path'] = path
1352
+ req['query'] = query
1353
+
1354
+ if path == '/ws/' and headers.get('upgrade','').lower() == 'websocket':
1355
+ client_id = str(uuid.uuid4())
1356
+ print(f"[HTTP->WS] Upgrade solicitado por {self.addr}, client_id={client_id}")
1357
+ self.handle_ws_connection(req, client_id)
1358
+ return
1359
+
1360
+ if path.startswith('/api/'):
1361
+ self.handle_api(req)
1362
+ self.conn.close()
1363
+ return
1364
+
1365
+ if path == '/' and method == 'GET':
1366
+ print(f"[HTTP] Sirviendo INDEX_HTML a {self.addr}")
1367
+ send_http_response(self.conn, 200, 'OK',
1368
+ {'Content-Type':'text/html; charset=utf-8'},
1369
+ INDEX_HTML)
1370
+ self.conn.close()
1371
+ return
1372
+
1373
+ print(f"[HTTP] 404 Not Found {path} desde {self.addr}")
1374
+ send_http_response(self.conn, 404, 'Not Found',
1375
+ {'Content-Type':'text/plain; charset=utf-8'},
1376
+ b'Not Found')
1377
+ self.conn.close()
1378
+ except Exception as e:
1379
+ print(f"[HTTP] Excepción manejando conexión desde {self.addr}: {e}")
1380
+ try:
1381
+ self.conn.close()
1382
+ except Exception:
1383
+ pass
1384
+
1385
+ # ----------------------------
1386
+ # API handler (por conexión, usa ClientRegistry + ORM)
1387
+ # ----------------------------
1388
+ def handle_api(self, req):
1389
+ method = req['method']
1390
+ path = req['path']
1391
+ headers = req['headers']
1392
+ body = req['remainder']
1393
+ query = req.get('query','') or ''
1394
+
1395
+ print(f"[API] {method} {path}?{query}")
1396
+
1397
+ if 'content-length' in headers:
1398
+ try:
1399
+ cl = int(headers['content-length'])
1400
+ except Exception:
1401
+ cl = 0
1402
+ if cl < 0:
1403
+ cl = 0
1404
+ if len(body) < cl:
1405
+ need = cl - len(body)
1406
+ if need > 0:
1407
+ body += recv_exact(self.conn, need)
1408
+
1409
+ if path == '/api/hello':
1410
+ if method.upper() == 'GET':
1411
+ print("[API] /api/hello GET")
1412
+ payload = '{"message":"Hello from server"}'.encode('utf-8')
1413
+ send_http_response(self.conn, 200, 'OK',
1414
+ {'Content-Type':'application/json; charset=utf-8'},
1415
+ payload)
1416
+ return True
1417
+ else:
1418
+ print("[API] /api/hello método no permitido")
1419
+ send_http_response(self.conn, 405, 'Method Not Allowed', {'Allow':'GET'}, b'')
1420
+ return True
1421
+
1422
+ if path == '/api/echo':
1423
+ if method.upper() == 'POST':
1424
+ try:
1425
+ body_text = body.decode('utf-8', errors='ignore')
1426
+ except Exception:
1427
+ body_text = ''
1428
+ print(f"[API] /api/echo cuerpo={body_text[:200]!r}")
1429
+ esc = body_text.replace('\\','\\\\').replace('"','\\"')
1430
+ payload = ('{"received":"%s"}' % esc).encode('utf-8')
1431
+ send_http_response(self.conn, 200, 'OK',
1432
+ {'Content-Type':'application/json; charset=utf-8'},
1433
+ payload)
1434
+ return True
1435
+ else:
1436
+ send_http_response(self.conn, 405, 'Method Not Allowed', {'Allow':'POST'}, b'')
1437
+ return True
1438
+
1439
+ if path == '/api/ws/clients':
1440
+ print("[API] /api/ws/clients listado")
1441
+ info = self.registry.list_clients_info()
1442
+ payload = json.dumps(info, default=str).encode('utf-8')
1443
+ send_http_response(self.conn, 200, 'OK',
1444
+ {'Content-Type':'application/json; charset=utf-8'},
1445
+ payload)
1446
+ return True
1447
+
1448
+ if path == '/api/ws/broadcast':
1449
+ if method.upper() != 'POST':
1450
+ send_http_response(self.conn, 405, 'Method Not Allowed', {'Allow':'POST'}, b'')
1451
+ return True
1452
+ try:
1453
+ jd = json.loads(body.decode('utf-8') or '{}')
1454
+ except Exception:
1455
+ jd = {}
1456
+ t = jd.get('type','text')
1457
+ message = jd.get('message','')
1458
+ print(f"[API] /api/ws/broadcast type={t} message={message[:100]!r}")
1459
+ if t == 'text':
1460
+ payload = message.encode('utf-8')
1461
+ failed = self.registry.broadcast(1, payload)
1462
+ else:
1463
+ b = jd.get('binary')
1464
+ if isinstance(b, list):
1465
+ payload = bytes(b)
1466
+ else:
1467
+ payload = (jd.get('message','')).encode('utf-8')
1468
+ failed = self.registry.broadcast(2, payload)
1469
+ if failed:
1470
+ print(f"[API] broadcast parcial, fallos={failed}")
1471
+ payload = json.dumps({'status':'partial','failed': failed}).encode('utf-8')
1472
+ send_http_response(self.conn, 500, 'Partial',
1473
+ {'Content-Type':'application/json; charset=utf-8'},
1474
+ payload)
1475
+ else:
1476
+ send_http_response(self.conn, 200, 'OK',
1477
+ {'Content-Type':'application/json; charset=utf-8'},
1478
+ json.dumps({'status':'ok'}).encode('utf-8'))
1479
+ return True
1480
+
1481
+ if path == '/api/ws/ping':
1482
+ if method.upper() != 'POST':
1483
+ send_http_response(self.conn, 405, 'Method Not Allowed', {'Allow':'POST'}, b'')
1484
+ return True
1485
+ try:
1486
+ jd = json.loads(body.decode('utf-8') or '{}')
1487
+ except Exception:
1488
+ jd = {}
1489
+ payload = jd.get('payload','').encode('utf-8')
1490
+ print(f"[API] /api/ws/ping payload={payload[:80]!r}")
1491
+ failed = self.registry.broadcast(9, payload)
1492
+ if failed:
1493
+ payload = json.dumps({'status':'partial','failed': failed}).encode('utf-8')
1494
+ send_http_response(self.conn, 500, 'Partial',
1495
+ {'Content-Type':'application/json; charset=utf-8'},
1496
+ payload)
1497
+ else:
1498
+ payload = json.dumps({'status':'ok'}).encode('utf-8')
1499
+ send_http_response(self.conn, 200, 'OK',
1500
+ {'Content-Type':'application/json; charset=utf-8'},
1501
+ payload)
1502
+ return True
1503
+
1504
+ if path == '/api/ws/close':
1505
+ if method.upper() != 'POST':
1506
+ send_http_response(self.conn, 405, 'Method Not Allowed', {'Allow':'POST'}, b'')
1507
+ return True
1508
+ try:
1509
+ jd = json.loads(body.decode('utf-8') or '{}')
1510
+ except Exception:
1511
+ jd = {}
1512
+ client_id = jd.get('client_id')
1513
+ code = int(jd.get('code', 1000))
1514
+ reason = jd.get('reason', '')
1515
+ if code < 0:
1516
+ code = 1000
1517
+ payload = code.to_bytes(2, 'big') + (reason.encode('utf-8') if reason else b'')
1518
+ print(f"[API] /api/ws/close client_id={client_id} code={code} reason={reason!r}")
1519
+ if client_id:
1520
+ ok, msg = self.registry.send_to_client(client_id, 8, payload)
1521
+ if ok:
1522
+ send_http_response(self.conn, 200, 'OK',
1523
+ {'Content-Type':'application/json; charset=utf-8'},
1524
+ b'{"status":"ok"}')
1525
+ else:
1526
+ send_http_response(self.conn, 500, 'Error',
1527
+ {'Content-Type':'application/json; charset=utf-8'},
1528
+ json.dumps({'status':'error','msg':msg}).encode('utf-8'))
1529
+ else:
1530
+ failed = self.registry.broadcast(8, payload)
1531
+ if failed:
1532
+ send_http_response(self.conn, 500, 'Partial',
1533
+ {'Content-Type':'application/json; charset=utf-8'},
1534
+ json.dumps({'status':'partial','failed':failed}).encode('utf-8'))
1535
+ else:
1536
+ send_http_response(self.conn, 200, 'OK',
1537
+ {'Content-Type':'application/json; charset=utf-8'},
1538
+ b'{"status":"ok"}')
1539
+ return True
1540
+
1541
+ # ----- API ORM: ChatMessage -----
1542
+ if path == '/api/chat/messages' and method.upper() == 'GET':
1543
+ params = parse_query_string(query)
1544
+ try:
1545
+ limit = int(params.get('limit', '50'))
1546
+ except Exception:
1547
+ limit = 50
1548
+ if limit <= 0:
1549
+ limit = 50
1550
+ if limit > 500:
1551
+ limit = 500
1552
+ msgs = ChatMessage.objects(self.db).order_by("id DESC").limit(limit).all()
1553
+ data = [m.to_dict() for m in msgs]
1554
+ payload = json.dumps(data).encode('utf-8')
1555
+ send_http_response(self.conn, 200, 'OK',
1556
+ {'Content-Type':'application/json; charset=utf-8'},
1557
+ payload)
1558
+ return True
1559
+
1560
+ if path == '/api/chat/clear' and method.upper() == 'POST':
1561
+ print("[API] /api/chat/clear -> borrando mensajes")
1562
+ with self.db.transaction():
1563
+ borrados = ChatMessage.objects(self.db).delete()
1564
+ payload = json.dumps({'status': 'ok', 'deleted': borrados}).encode('utf-8')
1565
+ send_http_response(self.conn, 200, 'OK',
1566
+ {'Content-Type':'application/json; charset=utf-8'},
1567
+ payload)
1568
+ return True
1569
+
1570
+ if path == '/' and method.upper() == 'GET':
1571
+ print("[API] GET / (INDEX_HTML) desde handle_api")
1572
+ send_http_response(self.conn, 200, 'OK',
1573
+ {'Content-Type':'text/html; charset=utf-8'},
1574
+ INDEX_HTML)
1575
+ return True
1576
+
1577
+ print(f"[API] 404 Not Found: {path}")
1578
+ send_http_response(self.conn, 404, 'Not Found',
1579
+ {'Content-Type':'text/plain; charset=utf-8'},
1580
+ b'Not Found')
1581
+ return True
1582
+
1583
+ # ----------------------------
1584
+ # WebSocket per-connection handler
1585
+ # ----------------------------
1586
+ def handle_ws_connection(self, initial_req, client_id):
1587
+ headers = initial_req['headers']
1588
+
1589
+ subprotocol = ''
1590
+ saw_proto = headers.get('sec-websocket-protocol','')
1591
+ if saw_proto:
1592
+ offered = [p.strip() for p in saw_proto.split(',') if p.strip()]
1593
+ if offered:
1594
+ subprotocol = offered[0]
1595
+
1596
+ key = headers.get('sec-websocket-key', '')
1597
+ if not key:
1598
+ print(f"[WS] Handshake inválido desde {self.addr}, falta Sec-WebSocket-Key")
1599
+ send_http_response(self.conn, 400, 'Bad Request',
1600
+ {'Content-Type':'text/plain; charset=utf-8'},
1601
+ b'Missing Sec-WebSocket-Key')
1602
+ try:
1603
+ self.conn.close()
1604
+ except Exception:
1605
+ pass
1606
+ return
1607
+
1608
+ print(f"[WS] Handshake desde {self.addr} client_id={client_id} subprotocols_ofrecidos={saw_proto!r}")
1609
+
1610
+ accept_src = (key + MAGIC_WS).encode('utf-8')
1611
+ digest = sha1(accept_src)
1612
+ accept = base64_encode(digest)
1613
+ resp_headers = {
1614
+ 'Upgrade': 'websocket',
1615
+ 'Connection': 'Upgrade',
1616
+ 'Sec-WebSocket-Accept': accept
1617
+ }
1618
+ if subprotocol:
1619
+ resp_headers['Sec-WebSocket-Protocol'] = subprotocol
1620
+
1621
+ send_http_response(self.conn, 101, 'Switching Protocols', resp_headers, b'')
1622
+
1623
+ self.registry.register_client(client_id, self.conn, self.addr, threading.current_thread(), subprotocol)
1624
+
1625
+ try:
1626
+ welcome = json.dumps({
1627
+ "type": "welcome",
1628
+ "client_id": client_id,
1629
+ "subprotocol": subprotocol
1630
+ }).encode('utf-8')
1631
+ self.conn.sendall(make_ws_frame_bytes(1, welcome))
1632
+ print(f"[WS] Enviado mensaje welcome a {client_id}")
1633
+ except Exception as e:
1634
+ print(f"[WS] Error enviando welcome a {client_id}: {e}")
1635
+
1636
+ fragmented_msg_opcode = None
1637
+ fragmented_parts = []
1638
+ try:
1639
+ while True:
1640
+ fin, opcode, payload, masked, mask = read_ws_frame_raw(self.conn)
1641
+ plen = len(payload)
1642
+ print(f"[WS RECV] cid={client_id} opcode={opcode} fin={fin} masked={masked} len={plen}")
1643
+
1644
+ if opcode == 0x8:
1645
+ code, reason = parse_close_payload(payload)
1646
+ print(f"[WS CLOSE] cid={client_id} code={code} reason={reason!r}")
1647
+ try:
1648
+ close_payload = payload if payload else b''
1649
+ self.conn.sendall(make_ws_frame_bytes(0x8, close_payload))
1650
+ except Exception as e:
1651
+ print(f"[WS] Error respondiendo CLOSE a {client_id}: {e}")
1652
+ break
1653
+ elif opcode == 0x9:
1654
+ print(f"[WS PING] cid={client_id} len={plen}")
1655
+ try:
1656
+ self.conn.sendall(make_ws_frame_bytes(0xA, payload))
1657
+ print(f"[WS PONG] enviado a cid={client_id}")
1658
+ except Exception as e:
1659
+ print(f"[WS] Error enviando PONG a {client_id}: {e}")
1660
+ continue
1661
+ elif opcode == 0xA:
1662
+ print(f"[WS PONG RECV] cid={client_id} len={plen}")
1663
+ continue
1664
+
1665
+ if opcode == 0x0:
1666
+ if fragmented_msg_opcode is None:
1667
+ print(f"[WS] CONTINUATION sin inicio previo en cid={client_id}, descartando fragmentos")
1668
+ fragmented_parts = []
1669
+ fragmented_msg_opcode = None
1670
+ continue
1671
+ fragmented_parts.append(payload)
1672
+ if fin:
1673
+ full = b''.join(fragmented_parts)
1674
+ if fragmented_msg_opcode == 1:
1675
+ try:
1676
+ text = full.decode('utf-8', errors='ignore')
1677
+ except Exception:
1678
+ text = ''
1679
+ print(f"[WS TEXT FRAG COMPLETO] cid={client_id} text={text[:120]!r}")
1680
+ self._handle_text_message(client_id, text, fragmented=True)
1681
+ elif fragmented_msg_opcode == 2:
1682
+ print(f"[WS BIN FRAG COMPLETO] cid={client_id} len={len(full)}")
1683
+ prefix = b"BIN ECHO:"
1684
+ self.conn.sendall(make_ws_frame_bytes(2, prefix + full))
1685
+ fragmented_parts = []
1686
+ fragmented_msg_opcode = None
1687
+ else:
1688
+ print(f"[WS] CONTINUATION (parte intermedia) cid={client_id} len_parte={plen}")
1689
+ elif opcode == 0x1 or opcode == 0x2:
1690
+ if fin:
1691
+ if opcode == 0x1:
1692
+ text = payload.decode('utf-8', errors='ignore')
1693
+ print(f"[WS TEXT] cid={client_id} text={text[:200]!r}")
1694
+ self._handle_text_message(client_id, text, fragmented=False)
1695
+ else:
1696
+ print(f"[WS BIN] cid={client_id} len={plen}")
1697
+ prefix = b"BIN ECHO:"
1698
+ self.conn.sendall(make_ws_frame_bytes(2, prefix + payload))
1699
+ else:
1700
+ fragmented_msg_opcode = opcode
1701
+ fragmented_parts = [payload]
1702
+ print(f"[WS] Inicio de mensaje fragmentado cid={client_id} opcode={opcode} len={plen}")
1703
+ else:
1704
+ print(f"[WS] Opcode reservado/no soportado {opcode} en cid={client_id}, ignorando.")
1705
+ continue
1706
+ except ConnectionError:
1707
+ print(f"[WS] ConnectionError en cid={client_id}")
1708
+ except Exception as e:
1709
+ print(f"[WS] Excepción en loop de cid={client_id}: {e}")
1710
+ finally:
1711
+ self.registry.unregister_client(client_id)
1712
+ try:
1713
+ self.conn.close()
1714
+ except Exception:
1715
+ pass
1716
+
1717
+ def _handle_text_message(self, client_id, text, fragmented=False):
1718
+ alias, msg = parse_chat_line(text)
1719
+ ts = int(time.time())
1720
+ if msg.strip():
1721
+ try:
1722
+ ChatMessage.objects(self.db).create(
1723
+ client_id=client_id,
1724
+ alias=alias or 'anon',
1725
+ message=msg,
1726
+ created_at=ts
1727
+ )
1728
+ print(f"[CHAT/ORM] Guardado mensaje chat cid={client_id} alias={alias or 'anon'} len={len(msg)}")
1729
+ except Exception as e:
1730
+ print(f"[CHAT/ORM] Error guardando mensaje: {e}")
1731
+ if fragmented:
1732
+ reply = "Server echo (frag): " + text
1733
+ else:
1734
+ reply = "Server echo: " + text
1735
+ try:
1736
+ self.conn.sendall(make_ws_frame_bytes(1, reply.encode('utf-8')))
1737
+ except Exception as e:
1738
+ print(f"[WS] Error enviando echo a cid={client_id}: {e}")
1739
+
1740
+ # ============================================================
1741
+ # Servidor principal
1742
+ # ============================================================
1743
+
1744
+ class WebSocketHTTPServer:
1745
+ def __init__(self, host, port):
1746
+ self.host = host
1747
+ self.port = port
1748
+ self.registry = ClientRegistry()
1749
+ self._sock = None
1750
+
1751
+ # BD en memoria compartida: file::memory:?cache=shared
1752
+ self.db = Database(
1753
+ path=":memory:",
1754
+ shared_cache=True,
1755
+ timeout=10.0,
1756
+ pragmas={
1757
+ "journal_mode": "WAL",
1758
+ "synchronous": "NORMAL",
1759
+ "foreign_keys": 1,
1760
+ },
1761
+ )
1762
+ # Crear tabla de ejemplo para el chat
1763
+ ChatMessage.create_table(self.db)
1764
+
1765
+ def serve_forever(self):
1766
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
1767
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1768
+ s.bind((self.host, self.port))
1769
+ s.listen(128)
1770
+ self._sock = s
1771
+ print("Servidor escuchando en http://%s:%d/ ..." % (self.host, self.port))
1772
+ try:
1773
+ while True:
1774
+ conn, addr = s.accept()
1775
+ print(f"[ACCEPT] Conexión aceptada desde {addr}")
1776
+ t = ConnectionThread(conn, addr, self.registry, self.db)
1777
+ t.start()
1778
+ except KeyboardInterrupt:
1779
+ print("\n[shutdown] interrupted by user (Ctrl+C). stopping server...")
1780
+ finally:
1781
+ s.close()
1782
+ print("[shutdown] server socket closed.")
1783
+
1784
+ def run_server(host, port):
1785
+ srv = WebSocketHTTPServer(host, port)
1786
+ srv.serve_forever()
1787
+
1788
+ if __name__ == '__main__':
1789
+ run_server(HOST, PORT)
1790
+
1791
+ # ============================================================
1792
+ # EJEMPLOS DE USO (HTTP / API / WS)
1793
+ # ============================================================
1794
+ # HTTP:
1795
+ # - Abrir en navegador: http://0.0.0.0:8765/
1796
+ #
1797
+ # API (ejemplos con curl):
1798
+ # - Listar clientes WS:
1799
+ # curl http://0.0.0.0:8765/api/ws/clients
1800
+ #
1801
+ # - Broadcast de texto:
1802
+ # curl -X POST http://0.0.0.0:8765/api/ws/broadcast \
1803
+ # -H "Content-Type: application/json" \
1804
+ # -d '{"type":"text","message":"Hola a todos desde curl"}'
1805
+ #
1806
+ # - Ping a todos:
1807
+ # curl -X POST http://0.0.0.0:8765/api/ws/ping \
1808
+ # -H "Content-Type: application/json" \
1809
+ # -d '{"payload":"ping-desde-api"}'
1810
+ #
1811
+ # - Cerrar todos los clientes:
1812
+ # curl -X POST http://0.0.0.0:8765/api/ws/close \
1813
+ # -H "Content-Type: application/json" \
1814
+ # -d '{"code":1000,"reason":"bye"}'
1815
+ #
1816
+ # - Listar mensajes de chat guardados en SQLite:
1817
+ # curl "http://0.0.0.0:8765/api/chat/messages?limit=20"
1818
+ #
1819
+ # - Borrar todos los mensajes de chat:
1820
+ # curl -X POST http://0.0.0.0:8765/api/chat/clear
1821
+ #
1822
+ # WebSocket (WS):
1823
+ # - Desde el HTML:
1824
+ # * Conectar con "Conectar WS".
1825
+ # * En la parte de chat:
1826
+ # - Pon un alias.
1827
+ # - Escribe un mensaje y "Enviar".
1828
+ # Se manda algo tipo: "[alias] mensaje".
1829
+ # El servidor:
1830
+ # - lo guarda en la tabla "chat_messages" (ORM SQLite)
1831
+ # - responde con "Server echo: [alias] mensaje" al mismo WS.
1832
+ #
1833
+ # - Desde herramientas WS (wscat, websocat, etc.):
1834
+ # wscat -c ws://0.0.0.0:8765/ws/
1835
+ # > [pepe] hola desde wscat
1836
+ # < Server echo: [pepe] hola desde wscat
1837
+ # Y el mensaje se almacena en "chat_messages".