Kgshop commited on
Commit
d22b2bd
·
verified ·
1 Parent(s): 2cf3684

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -2120
app.py DELETED
@@ -1,2120 +0,0 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """
4
- =============================================================================
5
- OPENCODE HOSTING PL.ATFORM v2 — один файл (app.py)
6
- =============================================================================
7
- Платформа хостинга opencode-чатов для клиентов на Hugging Face Space.
8
-
9
- НОВОЕ В v2:
10
- * Нативный быстрый клиентский UI (/c/<env_id>): PWA-манифест, 100dvh,
11
- инкрементальный рендер сообщений (poll с курсором, без перерисовки),
12
- оптимистичная отправка, вибрация, lightbox для медиа, тёмная тема.
13
- * МЕДИА: клиенты шлют ЛЮБЫЕ фото И видео (mp4/webm/mov/mkv, до 60 МБ);
14
- всё сохраняется в HF-датасет; владелец скачивает через галерею
15
- /admhosto/media/<env_id> (по файлу или всё zip-ом).
16
- * WHATSAPP WEB КОННЕКТОР (Baileys, node-мост встроен в этот файл):
17
- - QR-сканирование на странице среды;
18
- - с ТЕЛЕФОНА кнопка соединения не даёт сканировать: показывает
19
- требование открыть агента с компьютера и авторизоваться там;
20
- - соединение одноразовое: creds сохраняются (локально + в датасете),
21
- после рестарта Space переподключается само, повторно сканировать НЕ надо;
22
- - разрыв соединения кнопкой (logout, creds удаляются везде);
23
- - агент управляет WA по запросу: команды /wa ... в чате + авто-ответчик
24
- (входящие WA → opencode-агент → ответ в WA).
25
- * Хранение как в v1/примере: envs_data/<env>.json, chat_media/<env>/...,
26
- снапшот oc_home+wa_creds → opencode_state.tar.gz в HF-датасете.
27
-
28
- ПЕРЕМЕННЫЕ ОКРУЖЕНИЯ (Space secrets):
29
- REPO_ID, HF_TOKEN, HF_TOKEN_READ, ADMIN_PASSWORD,
30
- OPENCODE_EFFORT (default "low"), DEFAULT_MODEL, DEFAULT_VISION_MODEL,
31
- PORT/OPENCODE_PORT (7860), WA_ENABLED ("0" чтобы выключить WhatsApp)
32
-
33
- ТРЕБОВАНИЯ (requirements.txt):
34
- flask
35
- huggingface_hub
36
- pillow
37
- requests
38
- qrcode
39
- (node + npm-пакет @whiskeysockets/baileys — ставятся в Dockerfile)
40
-
41
- ПРИМЕР Dockerfile:
42
- FROM python:3.11-slim
43
- RUN apt-get update && apt-get install -y --no-install-recommends curl git nodejs npm \
44
- && rm -rf /var/lib/apt/lists/*
45
- RUN curl -fsSL https://opencode.ai/install | bash
46
- WORKDIR /app
47
- COPY requirements.txt .
48
- RUN pip install --no-cache-dir -r requirements.txt
49
- RUN npm install --no-audit --no-fund @whiskeysockets/baileys
50
- COPY app.py .
51
- EXPOSE 7860
52
- CMD ["python", "app.py"]
53
- =============================================================================
54
- """
55
-
56
- import os
57
- import io
58
- import re
59
- import sys
60
- import json
61
- import time
62
- import base64
63
- import atexit
64
- import shutil
65
- import signal
66
- import string
67
- import random
68
- import tarfile
69
- import tempfile
70
- import zipfile
71
- import subprocess
72
- import threading
73
- from uuid import uuid4
74
- from datetime import datetime, timedelta
75
-
76
- import requests
77
- from PIL import Image, ImageOps
78
- from flask import (Flask, request, redirect, url_for, flash, jsonify,
79
- session, send_from_directory, send_file, Response)
80
- from huggingface_hub import HfApi, hf_hub_download
81
- from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError
82
-
83
- try:
84
- import qrcode as _qrcode
85
- except Exception:
86
- _qrcode = None
87
-
88
- try:
89
- from dotenv import load_dotenv
90
- load_dotenv()
91
- except Exception:
92
- pass
93
-
94
- # ---------------------------------------------------------------------------
95
- # Flask
96
- # ---------------------------------------------------------------------------
97
- app = Flask(__name__)
98
- app.secret_key = os.environ.get("SECRET_KEY", "opencode_hosting_platform_secret_key_123")
99
- app.config.update(
100
- SESSION_COOKIE_SAMESITE='None',
101
- SESSION_COOKIE_SECURE=True,
102
- PERMANENT_SESSION_LIFETIME=timedelta(days=30),
103
- TEMPLATES_AUTO_RELOAD=False,
104
- JSON_SORT_KEYS=False,
105
- MAX_CONTENT_LENGTH=60 * 1024 * 1024, # видео до 60 МБ
106
- )
107
- app.jinja_env.auto_reload = False
108
- app.jinja_env.cache_size = 200
109
-
110
- _TEMPLATE_CACHE = {}
111
-
112
-
113
- def render_cached(template_string, **context):
114
- tpl = _TEMPLATE_CACHE.get(id(template_string))
115
- if tpl is None:
116
- tpl = app.jinja_env.from_string(template_string)
117
- _TEMPLATE_CACHE[id(template_string)] = tpl
118
- app.update_template_context(context)
119
- return tpl.render(context)
120
-
121
-
122
- def get_ts():
123
- return (datetime.utcnow() + timedelta(hours=3)).strftime('%Y-%m-%d %H:%M:%S')
124
-
125
-
126
- MOBILE_UA_RE = re.compile(r'iPhone|iPod|Android|Mobile|Silk|Opera Mini|IEMobile|Windows Phone', re.I)
127
- TABLET_UA_RE = re.compile(r'iPad|Tablet|SM-T|Tab|Kindle', re.I)
128
- DESKTOP_UA_RE = re.compile(r'Windows NT|Macintosh|X11|Linux x86', re.I)
129
-
130
-
131
- def is_mobile_request():
132
- ua = request.headers.get('User-Agent', '')
133
- if DESKTOP_UA_RE.search(ua) and not re.search(r'iPhone|iPod|Android.*Mobile', ua):
134
- return False
135
- return bool(MOBILE_UA_RE.search(ua)) and not TABLET_UA_RE.search(ua)
136
-
137
-
138
- # ---------------------------------------------------------------------------
139
- # HF DATASET STORAGE (схема как в примере app (4).txt)
140
- # ---------------------------------------------------------------------------
141
- REPO_ID = os.getenv("REPO_ID", "Kgshop/openag")
142
- HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
143
- HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
144
-
145
- DATA_DIR = 'envs_data'
146
- MEDIA_DIR = 'chat_media'
147
- OC_HOME = 'oc_home'
148
- WA_CREDS = 'wa_creds'
149
- OC_SNAPSHOT = 'opencode_state.tar.gz'
150
-
151
- for d in (DATA_DIR, MEDIA_DIR, OC_HOME, WA_CREDS):
152
- os.makedirs(d, exist_ok=True)
153
-
154
- data_lock = threading.RLock()
155
- _dirty_lock = threading.Lock()
156
- _DIRTY_ENV_IDS = set()
157
- _sync_event = threading.Event()
158
- _state_dirty = threading.Event()
159
-
160
-
161
- def download_db_from_hf(filename, retries=3, delay=5):
162
- if not REPO_ID:
163
- return False
164
- token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
165
- for attempt in range(retries + 1):
166
- try:
167
- hf_hub_download(
168
- repo_id=REPO_ID, filename=filename, repo_type="dataset",
169
- token=token_to_use, local_dir=".",
170
- local_dir_use_symlinks=False, force_download=True,
171
- resume_download=False,
172
- )
173
- return True
174
- except RepositoryNotFoundError:
175
- return False
176
- except HfHubHTTPError as e:
177
- resp = getattr(e, 'response', None)
178
- if resp is not None and resp.status_code == 404:
179
- return False
180
- except Exception:
181
- pass
182
- if attempt < retries:
183
- time.sleep(delay)
184
- return False
185
-
186
-
187
- def upload_file_to_hf(local_path, repo_path, commit_msg=None):
188
- if not (HF_TOKEN_WRITE and REPO_ID):
189
- return False
190
- try:
191
- HfApi().upload_file(
192
- path_or_fileobj=local_path, path_in_repo=repo_path,
193
- repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE,
194
- commit_message=commit_msg or f"Sync {repo_path} {get_ts()}",
195
- )
196
- return True
197
- except Exception:
198
- return False
199
-
200
-
201
- def delete_file_in_hf(repo_path):
202
- if not (HF_TOKEN_WRITE and REPO_ID):
203
- return
204
- try:
205
- HfApi().delete_file(path_in_repo=repo_path, repo_id=REPO_ID,
206
- repo_type="dataset", token=HF_TOKEN_WRITE,
207
- commit_message=f"Delete {repo_path}")
208
- except Exception:
209
- pass
210
-
211
-
212
- _REMOTE_FILES_CACHE = {'files': None, 'ts': 0}
213
-
214
-
215
- def list_remote_env_ids(force=False):
216
- if not REPO_ID:
217
- return []
218
- token = HF_TOKEN_READ or HF_TOKEN_WRITE
219
- now = time.time()
220
- if not force and _REMOTE_FILES_CACHE['files'] is not None and now - _REMOTE_FILES_CACHE['ts'] < 120:
221
- return _REMOTE_FILES_CACHE['files']
222
- try:
223
- files = HfApi().list_repo_files(repo_id=REPO_ID, repo_type='dataset', token=token)
224
- ids = [f[len(DATA_DIR) + 1:-5] for f in files
225
- if f.startswith(DATA_DIR + '/') and f.endswith('.json')]
226
- _REMOTE_FILES_CACHE['files'] = ids
227
- _REMOTE_FILES_CACHE['ts'] = now
228
- return ids
229
- except Exception:
230
- return _REMOTE_FILES_CACHE['files'] or []
231
-
232
-
233
- def _list_local_env_ids():
234
- try:
235
- return [fn[:-5] for fn in os.listdir(DATA_DIR)
236
- if fn.endswith('.json') and not fn.startswith('_')]
237
- except FileNotFoundError:
238
- return []
239
-
240
-
241
- def list_all_env_ids():
242
- ids = set(_list_local_env_ids())
243
- ids.update(list_remote_env_ids())
244
- return sorted(ids)
245
-
246
-
247
- # --- per-env JSON -----------------------------------------------------------
248
- _ENV_CACHE = {}
249
-
250
-
251
- def _env_file_path(env_id):
252
- return os.path.join(DATA_DIR, f'{env_id}.json')
253
-
254
-
255
- def _default_env_struct(env_id):
256
- return {
257
- 'id': env_id,
258
- 'settings': {
259
- 'name': f'Среда {env_id}',
260
- 'client_password_enabled': False,
261
- 'client_password': '',
262
- 'is_deleted': False,
263
- 'default_model': None,
264
- 'wa_auto_reply': False,
265
- 'wa_groups': False,
266
- 'wa_oc_sid': None,
267
- 'created_at': get_ts(),
268
- },
269
- 'session_order': [],
270
- 'sessions': {},
271
- 'wa_log': [],
272
- }
273
-
274
-
275
- def download_env_from_hf(env_id, retries=2, delay=2):
276
- return download_db_from_hf(f'{DATA_DIR}/{env_id}.json', retries=retries, delay=delay)
277
-
278
-
279
- def upload_env_to_hf(env_id):
280
- path = _env_file_path(env_id)
281
- if os.path.exists(path):
282
- upload_file_to_hf(path, f'{DATA_DIR}/{env_id}.json', f'Sync {env_id} {get_ts()}')
283
-
284
-
285
- def get_env_data(env_id):
286
- with data_lock:
287
- if env_id in _ENV_CACHE:
288
- return _ENV_CACHE[env_id]
289
- path = _env_file_path(env_id)
290
- env_data = None
291
- if os.path.exists(path):
292
- try:
293
- with open(path, 'r', encoding='utf-8') as f:
294
- env_data = json.load(f)
295
- except Exception:
296
- env_data = None
297
- if env_data is None:
298
- if download_env_from_hf(env_id):
299
- try:
300
- with open(path, 'r', encoding='utf-8') as f:
301
- env_data = json.load(f)
302
- except Exception:
303
- env_data = None
304
- if not isinstance(env_data, dict):
305
- return None
306
- base = _default_env_struct(env_id)
307
- base.update(env_data)
308
- base['settings'] = {**base['settings'], **(env_data.get('settings') or {})}
309
- base.setdefault('sessions', {})
310
- base.setdefault('session_order', [])
311
- base.setdefault('wa_log', [])
312
- with data_lock:
313
- _ENV_CACHE[env_id] = base
314
- return base
315
-
316
-
317
- def save_env_data(env_id, env_data):
318
- path = _env_file_path(env_id)
319
- with data_lock:
320
- _ENV_CACHE[env_id] = env_data
321
- fd, tmp = tempfile.mkstemp(dir=DATA_DIR, text=True)
322
- try:
323
- with os.fdopen(fd, 'w', encoding='utf-8') as f:
324
- json.dump(env_data, f, ensure_ascii=False)
325
- os.replace(tmp, path)
326
- except Exception:
327
- try:
328
- os.remove(tmp)
329
- except Exception:
330
- pass
331
- _queue_env_sync(env_id)
332
-
333
-
334
- def _queue_env_sync(env_id):
335
- with _dirty_lock:
336
- _DIRTY_ENV_IDS.add(env_id)
337
- _sync_event.set()
338
-
339
-
340
- def _background_sync_worker():
341
- while True:
342
- _sync_event.wait()
343
- time.sleep(2.5)
344
- with _dirty_lock:
345
- ids = list(_DIRTY_ENV_IDS)
346
- _DIRTY_ENV_IDS.clear()
347
- _sync_event.clear()
348
- for env_id in ids:
349
- try:
350
- upload_env_to_hf(env_id)
351
- except Exception:
352
- pass
353
- with _dirty_lock:
354
- if _DIRTY_ENV_IDS:
355
- _sync_event.set()
356
-
357
-
358
- def flush_dirty_envs():
359
- with _dirty_lock:
360
- ids = list(_DIRTY_ENV_IDS)
361
- _DIRTY_ENV_IDS.clear()
362
- _sync_event.clear()
363
- for env_id in ids:
364
- try:
365
- upload_env_to_hf(env_id)
366
- except Exception:
367
- pass
368
-
369
-
370
- # --- медиа (фото + видео) -----------------------------------------------------
371
- VIDEO_EXT = {'video/mp4': 'mp4', 'video/webm': 'webm', 'video/quicktime': 'mov',
372
- 'video/mpeg': 'mpg', 'video/x-matroska': 'mkv', 'video/3gpp': '3gp'}
373
- IMAGE_EXT = {'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp'}
374
-
375
-
376
- def save_incoming_media(file_storage, env_id):
377
- """Фото → WEBP-миниатюра через PIL; видео → как есть. Локально + в датасет."""
378
- try:
379
- mime = (file_storage.mimetype or '').split(';')[0].strip().lower()
380
- os.makedirs(os.path.join(MEDIA_DIR, env_id), exist_ok=True)
381
- if mime in VIDEO_EXT or mime.startswith('video/'):
382
- ext = VIDEO_EXT.get(mime, 'mp4')
383
- filename = f'{uuid4().hex}.{ext}'
384
- local = os.path.join(MEDIA_DIR, env_id, filename)
385
- file_storage.save(local)
386
- kind = 'video'
387
- elif mime.startswith('image/') or mime == '':
388
- img = Image.open(file_storage.stream)
389
- img = ImageOps.exif_transpose(img)
390
- if img.mode in ("RGBA", "P") and mime != 'image/gif':
391
- img = img.convert("RGB")
392
- img.thumbnail((1280, 1280), Image.Resampling.LANCZOS)
393
- buf = io.BytesIO()
394
- fmt = 'GIF' if mime == 'image/gif' else 'WEBP'
395
- if fmt == 'GIF':
396
- file_storage.stream.seek(0)
397
- raw = file_storage.stream.read()
398
- else:
399
- img.save(buf, format='WEBP', quality=80, method=6)
400
- raw = buf.getvalue()
401
- filename = f'{uuid4().hex}.{"gif" if fmt == "GIF" else "webp"}'
402
- with open(os.path.join(MEDIA_DIR, env_id, filename), 'wb') as f:
403
- f.write(raw)
404
- kind = 'image'
405
- else:
406
- return None, None
407
- local = os.path.join(MEDIA_DIR, env_id, filename)
408
- threading.Thread(target=upload_file_to_hf,
409
- args=(local, f'{MEDIA_DIR}/{env_id}/{filename}'),
410
- daemon=True).start()
411
- return filename, kind
412
- except Exception:
413
- return None, None
414
-
415
-
416
- def ensure_media_local(env_id, filename):
417
- local = os.path.join(MEDIA_DIR, env_id, filename)
418
- if os.path.exists(local):
419
- return local
420
- if download_db_from_hf(f'{MEDIA_DIR}/{env_id}/{filename}', retries=2, delay=1):
421
- if os.path.exists(local):
422
- return local
423
- return None
424
-
425
-
426
- def media_data_url(local):
427
- mime = 'image/webp' if local.endswith('.webp') else 'image/jpeg'
428
- with open(local, 'rb') as f:
429
- raw = f.read()
430
- return f'data:{mime};base64,' + base64.b64encode(raw).decode()
431
-
432
-
433
- MEDIA_NAME_RE = re.compile(r'^[0-9a-f]{32}\.(webp|gif|jpg|png|mp4|webm|mov|mpg|mkv|3gp)$')
434
-
435
-
436
- # --- снапшот состояния (opencode + wa creds) ---------------------------------
437
- def state_snapshot_make():
438
- tmp = OC_SNAPSHOT + '.tmp'
439
- try:
440
- with tarfile.open(tmp, 'w:gz') as tar:
441
- for d in (OC_HOME, WA_CREDS):
442
- if os.path.isdir(d):
443
- tar.add(d, arcname=d,
444
- filter=lambda ti: None if ti.name.endswith(('.lock', '.tmp')) else ti)
445
- os.replace(tmp, OC_SNAPSHOT)
446
- return True
447
- except Exception:
448
- try:
449
- os.remove(tmp)
450
- except Exception:
451
- pass
452
- return False
453
-
454
-
455
- def state_snapshot_upload():
456
- if os.path.exists(OC_SNAPSHOT):
457
- return upload_file_to_hf(OC_SNAPSHOT, OC_SNAPSHOT, f'Sync state {get_ts()}')
458
- return False
459
-
460
-
461
- def state_snapshot_restore():
462
- if download_db_from_hf(OC_SNAPSHOT, retries=2, delay=3):
463
- try:
464
- with tarfile.open(OC_SNAPSHOT, 'r:gz') as tar:
465
- tar.extractall('.')
466
- return True
467
- except Exception:
468
- return False
469
- return False
470
-
471
-
472
- def _state_snapshot_worker():
473
- while True:
474
- time.sleep(120)
475
- if _state_dirty.is_set():
476
- _state_dirty.clear()
477
- try:
478
- if state_snapshot_make():
479
- state_snapshot_upload()
480
- except Exception:
481
- pass
482
-
483
-
484
- def mark_state_dirty():
485
- _state_dirty.set()
486
-
487
-
488
- # ---------------------------------------------------------------------------
489
- # OPENCODE
490
- # ---------------------------------------------------------------------------
491
- OC_HOST = '127.0.0.1'
492
- OC_PORT = str(os.environ.get("OPENCODE_INTERNAL_PORT", "4096"))
493
- OC_BASE = f'http://{OC_HOST}:{OC_PORT}'
494
- EFFORT = os.environ.get("OPENCODE_EFFORT", "low").strip().lower() or "low"
495
- OC_WORKDIR = 'oc_project'
496
- os.makedirs(OC_WORKDIR, exist_ok=True)
497
-
498
- oc_proc = None
499
- oc_lock = threading.Lock()
500
-
501
- KNOWN_DEEPSEEK_FREE = ['deepseek-v4.1-flash-free', 'deepseek-v4-flash-free',
502
- 'deepseek-v4.1-flash', 'deepseek-v4-flash']
503
-
504
- MODELS_STATE = {'ready': False, 'all': [], 'default': None, 'vision': None, 'free': []}
505
-
506
-
507
- def write_opencode_config():
508
- models_cfg = {}
509
- for mid in KNOWN_DEEPSEEK_FREE:
510
- entry = {"variants": {"low": {"reasoningEffort": "low"},
511
- "medium": {"reasoningEffort": "medium"},
512
- "high": {"reasoningEffort": "high"}}}
513
- if EFFORT:
514
- entry["options"] = {"reasoningEffort": EFFORT}
515
- models_cfg[mid] = entry
516
- cfg = {
517
- "$schema": "https://opencode.ai/config.json",
518
- "model": f"opencode/{KNOWN_DEEPSEEK_FREE[0]}",
519
- "permission": {"*": "allow"},
520
- "provider": {"opencode": {"models": models_cfg}},
521
- }
522
- extra = os.environ.get("OPENCODE_EXTRA_CONFIG")
523
- if extra:
524
- try:
525
- cfg.update(json.loads(extra))
526
- except Exception:
527
- pass
528
- with open(os.path.join(OC_WORKDIR, 'opencode.json'), 'w', encoding='utf-8') as f:
529
- json.dump(cfg, f, ensure_ascii=False, indent=2)
530
-
531
-
532
- def find_opencode():
533
- found = shutil.which("opencode")
534
- if found:
535
- return found
536
- for c in (os.path.expanduser("~/.opencode/bin/opencode"),
537
- "/root/.opencode/bin/opencode",
538
- "/home/user/.opencode/bin/opencode"):
539
- if os.path.isfile(c) and os.access(c, os.X_OK):
540
- return c
541
- return None
542
-
543
-
544
- def spawn_opencode():
545
- global oc_proc
546
- binary = find_opencode()
547
- if not binary:
548
- print("[space] WARNING: opencode binary not found", flush=True)
549
- return None
550
- write_opencode_config()
551
- env = dict(os.environ)
552
- env['XDG_DATA_HOME'] = os.path.abspath(os.path.join(OC_HOME, 'data'))
553
- env['XDG_CONFIG_HOME'] = os.path.abspath(os.path.join(OC_HOME, 'config'))
554
- cmd = [binary, "serve", "--hostname", OC_HOST, "--port", OC_PORT]
555
- print(f"[space] starting: {' '.join(cmd)}", flush=True)
556
- oc_proc = subprocess.Popen(cmd, cwd=os.path.abspath(OC_WORKDIR), env=env)
557
- threading.Thread(target=wait_opencode_ready, daemon=True).start()
558
- return oc_proc
559
-
560
-
561
- def oc_get(path, timeout=15):
562
- try:
563
- r = requests.get(OC_BASE + path, timeout=timeout)
564
- if r.status_code == 200:
565
- return r.json()
566
- except Exception:
567
- pass
568
- return None
569
-
570
-
571
- def oc_post(path, payload=None, timeout=30):
572
- try:
573
- return requests.post(OC_BASE + path, json=payload or {}, timeout=timeout)
574
- except Exception:
575
- return None
576
-
577
-
578
- def wait_opencode_ready():
579
- for _ in range(90):
580
- if oc_get('/session') is not None:
581
- print("[space] opencode serve is ready", flush=True)
582
- refresh_models()
583
- return True
584
- time.sleep(2)
585
- return False
586
-
587
-
588
- def refresh_models():
589
- data = oc_get('/config/providers', timeout=20)
590
- if not data:
591
- return False
592
- entries = []
593
- for p in (data.get('providers') or []):
594
- pid = p.get('id') or ''
595
- models = p.get('models') or {}
596
- items = models.items() if isinstance(models, dict) else [(m.get('id'), m) for m in models if isinstance(m, dict)]
597
- for mid, m in items:
598
- if not mid or not isinstance(m, dict):
599
- continue
600
- mods = (m.get('modalities') or {}).get('input') or ['text']
601
- entries.append({'providerID': pid, 'modelID': mid, 'name': m.get('name') or mid,
602
- 'free': ('free' in (m.get('name') or '').lower()) or mid.endswith('-free') or mid == 'big-pickle',
603
- 'vision': 'image' in mods})
604
- with oc_lock:
605
- MODELS_STATE.update(all=entries, free=[e for e in entries if e['free']],
606
- default=pick_default_model(entries),
607
- vision=pick_vision_model(entries), ready=True)
608
- return True
609
-
610
-
611
- def pick_default_model(entries):
612
- forced = os.environ.get("DEFAULT_MODEL", "").strip()
613
- if forced and '/' in forced:
614
- pid, mid = forced.split('/', 1)
615
- hit = next((e for e in entries if e['providerID'] == pid and e['modelID'] == mid), None)
616
- if hit:
617
- return hit
618
- for want in KNOWN_DEEPSEEK_FREE:
619
- hit = next((e for e in entries if e['modelID'] == want), None)
620
- if hit:
621
- return hit
622
- hit = next((e for e in entries if e['free'] and 'deepseek' in e['name'].lower() and 'flash' in e['name'].lower()), None)
623
- return hit or next((e for e in entries if e['free']), None) or (entries[0] if entries else None)
624
-
625
-
626
- def pick_vision_model(entries):
627
- forced = os.environ.get("DEFAULT_VISION_MODEL", "").strip()
628
- if forced and '/' in forced:
629
- pid, mid = forced.split('/', 1)
630
- hit = next((e for e in entries if e['providerID'] == pid and e['modelID'] == mid), None)
631
- if hit:
632
- return hit
633
- return (next((e for e in entries if e['free'] and e['vision']), None)
634
- or next((e for e in entries if e['vision']), None))
635
-
636
-
637
- def model_for_message(env_data, has_images):
638
- with oc_lock:
639
- default, vision = MODELS_STATE['default'], MODELS_STATE['vision']
640
- chosen = (env_data or {}).get('settings', {}).get('default_model') or default
641
- if not chosen:
642
- return None, False
643
- if has_images and not chosen.get('vision') and vision:
644
- return vision, True
645
- return chosen, False
646
-
647
-
648
- # ---------------------------------------------------------------------------
649
- # WHATSAPP: node-мост на Baileys (встроен в этот файл)
650
- # ---------------------------------------------------------------------------
651
- WA_ENABLED = os.environ.get("WA_ENABLED", "1") != "0"
652
- WA_PORT = str(os.environ.get("WA_INTERNAL_PORT", "4100"))
653
- WA_BASE = f'http://127.0.0.1:{WA_PORT}'
654
- WA_TOKEN = uuid4().hex
655
- wa_proc = None
656
-
657
- BRIDGE_JS = r"""
658
- // wa_bridge.js — WhatsApp Web bridge (Baileys). Управляется из app.py.
659
- const http = require('http');
660
- const fs = require('fs');
661
- const path = require('path');
662
- const crypto = require('crypto');
663
- const B = require('@whiskeysockets/baileys');
664
- const makeWASocket = B.default || B.makeWASocket;
665
- const { useMultiFileAuthState, fetchLatestBaileysVersion, DisconnectReason,
666
- downloadMediaMessage, isJidGroup } = B;
667
-
668
- const PORT = parseInt(process.env.WA_PORT || '4100', 10);
669
- const TOKEN = process.env.WA_TOKEN || '';
670
- const CREDS = process.env.WA_CREDS_DIR || 'wa_creds';
671
- const MEDIA = process.env.WA_MEDIA_DIR || 'chat_media';
672
- const FLASK = process.env.WA_FLASK_URL || 'http://127.0.0.1:7860';
673
- const log = (...a) => console.log('[wa]', ...a);
674
- const S = {};
675
-
676
- fs.mkdirSync(CREDS, { recursive: true });
677
- fs.mkdirSync(MEDIA, { recursive: true });
678
-
679
- function mediaDir(env) { const d = path.join(MEDIA, env); fs.mkdirSync(d, { recursive: true }); return d; }
680
- function hasCreds(env) { return fs.existsSync(path.join(CREDS, env, 'creds.json')); }
681
-
682
- async function flaskEvent(env, type, payload) {
683
- try {
684
- await fetch(FLASK + '/wa/event', {
685
- method: 'POST',
686
- headers: { 'Content-Type': 'application/json', 'x-wa-token': TOKEN },
687
- body: JSON.stringify({ env, type, payload }),
688
- });
689
- } catch (e) { log('flask event err:', e.message); }
690
- }
691
-
692
- async function saveWaMedia(env, m, msg) {
693
- try {
694
- const im = msg.imageMessage, vm = msg.videoMessage;
695
- const node = im ? { ...m, message: { imageMessage: im } } : { ...m, message: { videoMessage: vm } };
696
- const buf = await downloadMediaMessage(node, 'buffer', {});
697
- const mime = (im ? im.mimetype : vm.mimetype) || '';
698
- const ext = mime.includes('mp4') ? 'mp4' : mime.includes('webm') ? 'webm'
699
- : mime.includes('gif') ? 'gif' : mime.includes('png') ? 'png'
700
- : mime.includes('webp') ? 'webp' : (im ? 'jpg' : 'mp4');
701
- const name = crypto.randomUUID().replace(/-/g, '') + '.' + ext;
702
- fs.writeFileSync(path.join(mediaDir(env), name), buf);
703
- return { name, kind: im ? 'image' : 'video' };
704
- } catch (e) { log('media dl err:', e.message); return null; }
705
- }
706
-
707
- async function start(env) {
708
- const cur = S[env];
709
- if (cur && (cur.sock || cur.starting)) return { ok: true, already: true };
710
- const st = S[env] = { sock: null, qr: null, status: 'connecting', info: null, history: [], starting: true };
711
- try {
712
- const authState = await useMultiFileAuthState(path.join(CREDS, env));
713
- const state = authState.state || authState;
714
- const saveCreds = authState.saveCreds || (async () => {});
715
- let version;
716
- try { version = (await fetchLatestBaileysVersion()).version; } catch (e) { version = undefined; }
717
- const sock = makeWASocket({
718
- version, auth: state, printQRInTerminal: false,
719
- browser: ['OpenCode Hosting', 'Chrome', '120.0'],
720
- syncFullHistory: false, markOnlineOnConnect: true,
721
- });
722
- st.sock = sock;
723
- sock.ev.on('creds.update', saveCreds);
724
- sock.ev.on('connection.update', (up) => {
725
- if (up.qr) { st.qr = up.qr; st.status = 'qr'; flaskEvent(env, 'status', { status: 'qr' }); }
726
- if (up.connection === 'open') {
727
- st.qr = null; st.status = 'connected'; st.info = sock.user || null;
728
- flaskEvent(env, 'status', { status: 'connected', info: st.info });
729
- }
730
- if (up.connection === 'close') {
731
- const code = up.lastError && (up.lastError.output ? up.lastError.output.statusCode : up.lastError.message);
732
- const loggedOut = code === 401 || code === DisconnectReason.loggedOut;
733
- st.qr = null; st.status = loggedOut ? 'logged_out' : 'disconnected'; st.sock = null;
734
- flaskEvent(env, 'status', { status: st.status });
735
- if (loggedOut) { delete S[env]; }
736
- else { setTimeout(() => { start(env).catch(() => {}); }, 5000); }
737
- }
738
- });
739
- sock.ev.on('messages.upsert', async ({ messages, type }) => {
740
- if (type !== 'notify') return;
741
- for (const m of messages || []) {
742
- try {
743
- const msg = m.message || {};
744
- const text = msg.conversation || (msg.extendedTextMessage && msg.extendedTextMessage.text)
745
- || (msg.imageMessage && msg.imageMessage.caption)
746
- || (msg.videoMessage && msg.videoMessage.caption) || '';
747
- let media = null;
748
- if (msg.imageMessage || msg.videoMessage) media = await saveWaMedia(env, m, msg);
749
- const rec = {
750
- id: m.key.id, jid: m.key.remoteJid || '', fromMe: !!m.key.fromMe,
751
- pushName: m.pushName || '', text: text || (media ? '[' + media.kind + ']' : ''),
752
- ts: Date.now(), group: isJidGroup(m.key.remoteJid || ''), media,
753
- };
754
- st.history.push(rec);
755
- if (st.history.length > 300) st.history.shift();
756
- flaskEvent(env, 'message', rec);
757
- } catch (e) { log('upsert err:', e.message); }
758
- }
759
- });
760
- st.starting = false;
761
- return { ok: true };
762
- } catch (e) {
763
- st.starting = false; st.status = 'error'; st.error = String(e.message || e);
764
- return { ok: false, error: st.error };
765
- }
766
- }
767
-
768
- function normJid(x) {
769
- x = String(x || '').trim();
770
- if (x.includes('@')) return x;
771
- return x.replace(/[^\d]/g, '') + '@s.whatsapp.net';
772
- }
773
-
774
- const server = http.createServer(async (req, res) => {
775
- const u = new URL(req.url, 'http://x');
776
- const send = (code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
777
- if ((req.headers['x-wa-token'] || '') !== TOKEN) return send(401, { ok: false, error: 'auth' });
778
- let body = {};
779
- if (req.method === 'POST') {
780
- body = await new Promise(r => { let d = ''; req.on('data', c => d += c); req.on('end', () => { try { r(JSON.parse(d || '{}')); } catch (e) { r({}); } }); });
781
- }
782
- const env = u.searchParams.get('env') || body.env || '';
783
- const st = S[env];
784
- try {
785
- if (u.pathname === '/ping') return send(200, { ok: true });
786
- if (u.pathname === '/envs') {
787
- const dirs = fs.existsSync(CREDS) ? fs.readdirSync(CREDS).filter(d => hasCreds(d)) : [];
788
- return send(200, { ok: true, envs: dirs.map(d => ({ env: d, status: (S[d] || {}).status || 'stored' })) });
789
- }
790
- if (u.pathname === '/status')
791
- return send(200, { ok: true, status: st ? st.status : (hasCreds(env) ? 'stored' : 'none'),
792
- info: st ? st.info : null, hasCreds: hasCreds(env) });
793
- if (u.pathname === '/qr') return send(200, { ok: true, qr: st ? st.qr : null });
794
- if (u.pathname === '/history')
795
- return send(200, { ok: true, history: st ? st.history.slice(-(parseInt(u.searchParams.get('limit') || '50', 10))) : [] });
796
- if (u.pathname === '/start') { const r2 = await start(env); return send(200, r2); }
797
- if (u.pathname === '/logout') {
798
- if (st && st.sock) { try { await st.sock.logout(); } catch (e) {} }
799
- delete S[env];
800
- try { fs.rmSync(path.join(CREDS, env), { recursive: true, force: true }); } catch (e) {}
801
- flaskEvent(env, 'status', { status: 'logged_out' });
802
- return send(200, { ok: true });
803
- }
804
- if (u.pathname === '/send') {
805
- if (!st || !st.sock || st.status !== 'connected') return send(200, { ok: false, error: 'not connected' });
806
- const jid = normJid(body.jid || body.phone);
807
- await st.sock.sendMessage(jid, { text: String(body.text || '') });
808
- const rec = { id: 'out' + Date.now(), jid, fromMe: true, pushName: '', text: String(body.text || ''), ts: Date.now(), group: isJidGroup(jid), media: null };
809
- st.history.push(rec);
810
- return send(200, { ok: true, jid });
811
- }
812
- return send(404, { ok: false });
813
- } catch (e) {
814
- return send(200, { ok: false, error: String(e.message || e) });
815
- }
816
- });
817
- server.listen(PORT, '127.0.0.1', () => log('bridge on', PORT));
818
-
819
- // авто-переподключение сред с сохранёнными creds (соединение одноразовое)
820
- try {
821
- for (const d of fs.readdirSync(CREDS)) {
822
- if (hasCreds(d)) { log('auto-start stored env', d); start(d).catch(() => {}); }
823
- }
824
- } catch (e) {}
825
- """
826
-
827
-
828
- def write_bridge():
829
- path_ = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'wa_bridge.js')
830
- with open(path_, 'w', encoding='utf-8') as f:
831
- f.write(BRIDGE_JS)
832
- return path_
833
-
834
-
835
- def find_node():
836
- return shutil.which('node')
837
-
838
-
839
- def wa_available():
840
- return WA_ENABLED and bool(find_node())
841
-
842
-
843
- def spawn_wa_bridge():
844
- global wa_proc
845
- if not wa_available():
846
- print("[space] WhatsApp bridge disabled (no node or WA_ENABLED=0)", flush=True)
847
- return None
848
- node_modules = os.path.dirname(os.path.abspath(__file__))
849
- env = dict(os.environ)
850
- env.update({
851
- 'WA_PORT': WA_PORT, 'WA_TOKEN': WA_TOKEN,
852
- 'WA_CREDS_DIR': os.path.abspath(WA_CREDS),
853
- 'WA_MEDIA_DIR': os.path.abspath(MEDIA_DIR),
854
- 'WA_FLASK_URL': f'http://127.0.0.1:{os.environ.get("OPENCODE_PORT", os.environ.get("PORT", "7860"))}',
855
- })
856
- cmd = [find_node(), write_bridge()]
857
- print(f"[space] starting wa bridge: {' '.join(cmd)}", flush=True)
858
- wa_proc = subprocess.Popen(cmd, cwd=node_modules, env=env)
859
- return wa_proc
860
-
861
-
862
- def wb_get(path, timeout=8):
863
- try:
864
- r = requests.get(WA_BASE + path, headers={'x-wa-token': WA_TOKEN}, timeout=timeout)
865
- if r.status_code == 200:
866
- return r.json()
867
- except Exception:
868
- pass
869
- return None
870
-
871
-
872
- def wb_post(path, payload=None, timeout=20):
873
- try:
874
- r = requests.post(WA_BASE + path, json=payload or {},
875
- headers={'x-wa-token': WA_TOKEN}, timeout=timeout)
876
- if r.status_code == 200:
877
- return r.json()
878
- except Exception:
879
- pass
880
- return None
881
-
882
-
883
- def wa_status(env_id):
884
- if not wa_available():
885
- return {'status': 'disabled'}
886
- d = wb_get(f'/status?env={env_id}')
887
- if not d:
888
- return {'status': 'bridge_offline'}
889
- return d
890
-
891
-
892
- def wa_append_log(env_data, rec, cap=300):
893
- log_ = env_data.setdefault('wa_log', [])
894
- log_.append(rec)
895
- if len(log_) > cap:
896
- del log_[:len(log_) - cap]
897
-
898
-
899
- def wa_autoreply_thread(env_id, rec):
900
- """Входящее WA → opencode-агент → ответ в WA."""
901
- env_data = get_env_data(env_id)
902
- if not env_data:
903
- return
904
- sid = env_data['settings'].get('wa_oc_sid')
905
- if not sid or not env_data['sessions'].get(sid):
906
- sid = uuid4().hex[:12]
907
- env_data['sessions'][sid] = {'id': sid, 'title': 'WhatsApp авто-агент',
908
- 'created_at': get_ts(), 'oc_sid': None,
909
- 'saved_assistant_ids': [], 'messages': []}
910
- env_data['session_order'].insert(0, sid)
911
- env_data['settings']['wa_oc_sid'] = sid
912
- save_env_data(env_id, env_data)
913
- s = env_data['sessions'][sid]
914
- if not s.get('oc_sid'):
915
- r = oc_post('/session', {'title': 'WhatsApp агент'})
916
- if r is None or r.status_code not in (200, 201):
917
- return
918
- s['oc_sid'] = (r.json() or {}).get('id')
919
- if not s['oc_sid']:
920
- return
921
- save_env_data(env_id, env_data)
922
- who = f"{rec.get('pushName') or 'Контакт'} ({rec.get('jid', '').split('@')[0]})"
923
- prompt = (f"[WhatsApp] Входящее сообщение от {who}: «{rec.get('text', '')}». "
924
- f"Ответь от имени владельца коротко и по делу; верни ТОЛЬКО текст ответа.")
925
- seen = {m.get('info', {}).get('id') for m in (oc_get(f'/session/{s["oc_sid"]}/message') or [])}
926
- r = oc_post(f'/session/{s["oc_sid"]}/prompt_async',
927
- {'parts': [{'type': 'text', 'text': prompt}]})
928
- if r is None or r.status_code not in (200, 204):
929
- return
930
- deadline = time.time() + 180
931
- reply = None
932
- while time.time() < deadline:
933
- time.sleep(2)
934
- for m in (oc_get(f'/session/{s["oc_sid"]}/message') or []):
935
- info = m.get('info') or {}
936
- mid = info.get('id')
937
- if info.get('role') == 'assistant' and mid not in seen:
938
- t = info.get('time') or {}
939
- if t.get('completed') or info.get('error'):
940
- reply = ''.join(p.get('text', '') for p in (m.get('parts') or []) if p.get('type') == 'text')
941
- break
942
- if reply is not None:
943
- break
944
- if reply and reply.strip():
945
- wb_post('/send', {'env': env_id, 'jid': rec.get('jid'), 'text': reply.strip()[:4000]})
946
- env_data = get_env_data(env_id)
947
- if env_data:
948
- wa_append_log(env_data, {'id': 'ar' + str(int(time.time())), 'jid': rec.get('jid'),
949
- 'fromMe': True, 'pushName': 'Агент', 'text': reply.strip()[:500],
950
- 'ts': int(time.time() * 1000), 'group': False, 'media': None})
951
- save_env_data(env_id, env_data)
952
-
953
-
954
- # ---------------------------------------------------------------------------
955
- # Шаблоны
956
- # ---------------------------------------------------------------------------
957
- BASE_CSS = """
958
- :root{--bg:#0b0d10;--panel:#15181d;--panel2:#1c2026;--border:#2a2f37;--text:#e8eaed;
959
- --muted:#9aa0a6;--accent:#4f9cf9;--green:#3ddc84;--red:#ea6060;--radius:14px;
960
- --sab:env(safe-area-inset-bottom,0px)}
961
- *{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
962
- html{height:100%}
963
- body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',sans-serif;font-size:15px;line-height:1.45;height:100%}
964
- a{color:var(--accent);text-decoration:none}
965
- button{font-family:inherit}
966
- .wrap{max-width:960px;margin:0 auto;padding:16px}
967
- .card{background:var(--panel);border:1px solid var(--border);border-radius:var(--radius);padding:16px;margin-bottom:14px}
968
- h1{font-size:20px;margin-bottom:4px}h2{font-size:16px;margin-bottom:10px}
969
- .muted{color:var(--muted);font-size:13px}
970
- .badge{display:inline-block;background:var(--panel2);border:1px solid var(--border);border-radius:8px;padding:2px 8px;font-size:12px;color:var(--muted);margin-left:6px}
971
- .badge.free{color:#7ee2a8;border-color:#24513a}
972
- .btn{display:inline-block;background:var(--accent);color:#fff;border:none;border-radius:10px;padding:9px 14px;font-size:14px;cursor:pointer;touch-action:manipulation;user-select:none}
973
- .btn:active{transform:scale(.97)}
974
- .btn.sec{background:var(--panel2);color:var(--text);border:1px solid var(--border)}
975
- .btn.danger{background:#3a1d21;color:#ff9d9d;border:1px solid #5c2b30}
976
- .btn.small{padding:5px 10px;font-size:12px;border-radius:8px}
977
- .btn[disabled]{opacity:.5;pointer-events:none}
978
- input[type=text],input[type=password],textarea,select{width:100%;background:var(--panel2);border:1px solid var(--border);color:var(--text);border-radius:10px;padding:9px 12px;font-size:16px;font-family:inherit}
979
- table{width:100%;border-collapse:collapse;font-size:14px}
980
- th,td{padding:8px 6px;border-bottom:1px solid var(--border);text-align:left;vertical-align:top}
981
- .flash{padding:10px 12px;border-radius:10px;margin-bottom:10px;font-size:14px}
982
- .flash.success{background:#15301f;border:1px solid #2c5c3d;color:#8fe0ac}
983
- .flash.error{background:#3a1d21;border:1px solid #5c2b30;color:#ff9d9d}
984
- .row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
985
- .dot{width:9px;height:9px;border-radius:50%;display:inline-block;margin-right:6px}
986
- .dot.on{background:var(--green)}.dot.off{background:#666}.dot.qr{background:#f5c542}
987
- .mgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:10px}
988
- .mgrid .mi{background:var(--panel2);border:1px solid var(--border);border-radius:12px;overflow:hidden;position:relative}
989
- .mgrid .mi img,.mgrid .mi video{width:100%;height:120px;object-fit:cover;display:block}
990
- .mgrid .mi .mn{padding:6px 8px;font-size:11px;color:var(--muted);display:flex;justify-content:space-between;gap:6px;align-items:center}
991
- """
992
-
993
- ADMHOSTO_TEMPLATE = """<!doctype html><html lang="ru"><head><meta charset="utf-8">
994
- <meta name="viewport" content="width=device-width,initial-scale=1">
995
- <title>admhosto — админ-панель сред</title>
996
- <style>""" + BASE_CSS + """</style></head><body><div class="wrap">
997
- <h1>Админ-панель сред <span class="badge">/admhosto</span></h1>
998
- <p class="muted">Хранилище: HF dataset <b>{{ repo_id or 'НЕ ЗАДАН (REPO_ID)' }}</b> ·
999
- opencode: {{ 'online' if oc_ready else 'offline' }} · wa-bridge: {{ wa_state }} ·
1000
- модель: <b>{{ default_model_name }}</b> ({{ effort }})</p>
1001
- {% with messages = get_flashed_messages(with_categories=true) %}{% for cat, msg in messages %}
1002
- <div class="flash {{ cat }}">{{ msg }}</div>{% endfor %}{% endwith %}
1003
-
1004
- <div class="card"><h2>Создать среду для клиента</h2>
1005
- <form method="post" action="/admhosto/create" class="row">
1006
- <button class="btn" type="submit">+ Создать среду</button>
1007
- <span class="muted">ID — 6 цифр, клиент заходит по /c/&lt;id&gt;</span>
1008
- </form></div>
1009
-
1010
- <div class="card"><h2>Активные среды ({{ active_envs|length }})</h2>
1011
- {% if not active_envs %}<p class="muted">Пока нет ни ��дной среды.</p>{% endif %}
1012
- <table><tr><th>ID / имя</th><th>Клиент</th><th>Пароль</th><th>WhatsApp</th><th>Медиа</th><th>Управление</th></tr>
1013
- {% for e in active_envs %}<tr>
1014
- <td><b>{{ e.id }}</b><br><span class="muted">{{ e.name }}</span>
1015
- <form method="post" action="/admhosto/rename/{{ e.id }}" class="row" style="margin-top:6px">
1016
- <input type="text" name="name" value="{{ e.name }}"><button class="btn sec small">Имя</button></form></td>
1017
- <td><a href="/c/{{ e.id }}" target="_blank">/c/{{ e.id }}</a><br><span class="muted">{{ e.sessions_count }} сесс.</span></td>
1018
- <td>{% if e.pwd_enabled %}вкл<br><span class="muted">{{ e.password }}</span>{% else %}<span class="muted">выкл</span>{% endif %}
1019
- <form method="post" action="/admhosto/update_pwd/{{ e.id }}" class="row" style="margin-top:6px">
1020
- <input type="text" name="password" value="{{ e.password }}" placeholder="пароль">
1021
- <label class="muted"><input type="checkbox" name="pwd_enabled" {% if e.pwd_enabled %}checked{% endif %}></label>
1022
- <button class="btn sec small">Ок</button></form></td>
1023
- <td><span class="dot {{ 'on' if e.wa == 'connected' else ('qr' if e.wa == 'qr' else 'off') }}"></span>{{ e.wa }}
1024
- {% if e.wa == 'connected' %}<form method="post" action="/admhosto/wa_logout/{{ e.id }}" style="margin-top:6px">
1025
- <button class="btn danger small">Разорвать</button></form>{% endif %}</td>
1026
- <td><a class="btn sec small" href="/admhosto/media/{{ e.id }}">Галерея ({{ e.media_count }})</a></td>
1027
- <td><form method="post" action="/admhosto/clear_history/{{ e.id }}" style="margin-bottom:6px">
1028
- <button class="btn sec small" onclick="return confirm('Очистить историю чатов?')">Очистить</button></form>
1029
- <form method="post" action="/admhosto/delete/{{ e.id }}">
1030
- <button class="btn danger small" onclick="return confirm('В архив?')">В архив</button></form></td>
1031
- </tr>{% endfor %}</table></div>
1032
-
1033
- {% if archived_envs %}<div class="card"><h2>Архив ({{ archived_envs|length }})</h2>
1034
- <table>{% for e in archived_envs %}<tr><td><b>{{ e.id }}</b> <span class="muted">{{ e.name }}</span></td>
1035
- <td><form method="post" action="/admhosto/restore/{{ e.id }}" style="display:inline"><button class="btn sec small">Восстановить</button></form>
1036
- <form method="post" action="/admhosto/hard_delete/{{ e.id }}" style="display:inline" onsubmit="return confirm('Удалить НАВСЕГДА?')"><button class="btn danger small">Удалить</button></form></td></tr>{% endfor %}</table></div>{% endif %}
1037
-
1038
- <div class="card"><h2>Диагностика</h2><p class="muted">
1039
- HF_TOKEN: {{ 'задан' if hf_write else 'НЕТ' }} · ADMIN_PASSWORD: {{ 'задан' if admin_pwd else 'нет' }} ·
1040
- node: {{ 'есть' if node_ok else 'НЕТ (WA выключен)' }}<br>
1041
- Бесплатные модели: {% for m in free_models %}{{ m.name }}{% if not loop.last %}, {% endif %}{% else %}<i>нет</i>{% endfor %}</p></div>
1042
- </div></body></html>"""
1043
-
1044
- MEDIA_TEMPLATE = """<!doctype html><html lang="ru"><head><meta charset="utf-8">
1045
- <meta name="viewport" content="width=device-width,initial-scale=1">
1046
- <title>Медиа среды {{ env_id }}</title><style>""" + BASE_CSS + """</style></head><body><div class="wrap">
1047
- <h1>Медиа: среда {{ env_id }}</h1>
1048
- <p class="muted"><a href="/admhosto">&larr; в админку</a> · файлов: {{ items|length }} ·
1049
- <a class="btn sec small" href="/admhosto/media/{{ env_id }}/zip">Скачать всё (.zip)</a></p>
1050
- {% if not items %}<p class="muted">Медиа пока нет.</p>{% endif %}
1051
- <div class="mgrid">{% for it in items %}
1052
- <div class="mi">
1053
- {% if it.kind == 'video' %}<video src="/files/{{ env_id }}/{{ it.name }}" preload="metadata" muted playsinline></video>
1054
- {% else %}<img src="/files/{{ env_id }}/{{ it.name }}" loading="lazy">{% endif %}
1055
- <div class="mn"><span>{{ it.size }}</span>
1056
- <a class="btn small" href="/files/{{ env_id }}/{{ it.name }}?download=1">Скачать</a></div>
1057
- </div>{% endfor %}</div>
1058
- </div></body></html>"""
1059
-
1060
- LOGIN_TEMPLATE = """<!doctype html><html lang="ru"><head><meta charset="utf-8">
1061
- <meta name="viewport" content="width=device-width,initial-scale=1"><title>Вход</title>
1062
- <style>""" + BASE_CSS + """.box{max-width:360px;margin:14vh auto}</style></head><body><div class="wrap"><div class="box card">
1063
- <h1>{{ title }}</h1><p class="muted" style="margin-bottom:12px">{{ subtitle }}</p>
1064
- {% with messages = get_flashed_messages(with_categories=true) %}{% for cat, msg in messages %}
1065
- <div class="flash {{ cat }}">{{ msg }}</div>{% endfor %}{% endwith %}
1066
- <form method="post"><input type="password" name="password" placeholder="Пароль" autofocus>
1067
- <div style="height:10px"></div><button class="btn" style="width:100%">Войти</button></form>
1068
- </div></div></body></html>"""
1069
-
1070
- DEAD_TEMPLATE = """<!doctype html><html lang="ru"><head><meta charset="utf-8">
1071
- <meta name="viewport" content="width=device-width,initial-scale=1"><title>Недоступно</title>
1072
- <style>""" + BASE_CSS + """</style></head><body><div class="wrap">
1073
- <div class="card" style="max-width:420px;margin:18vh auto;text-align:center">
1074
- <h1>{{ title }}</h1><p class="muted" style="margin-top:8px">{{ subtitle }}</p>
1075
- </div></div></body></html>"""
1076
-
1077
- CHAT_TEMPLATE = """<!doctype html><html lang="ru"><head><meta charset="utf-8">
1078
- <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,user-scalable=no">
1079
- <meta name="theme-color" content="#0b0d10">
1080
- <meta name="apple-mobile-web-app-capable" content="yes">
1081
- <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
1082
- <link rel="manifest" href="/manifest.json">
1083
- <link rel="apple-touch-icon" href="/icon.png"><link rel="icon" href="/icon.png">
1084
- <title>{{ env_name }}</title>
1085
- <style>""" + BASE_CSS + """
1086
- html,body{height:100%;overscroll-behavior:none}
1087
- body{display:flex;flex-direction:column;overflow:hidden;position:fixed;inset:0;width:100%}
1088
- header{display:flex;align-items:center;gap:8px;padding:calc(8px + env(safe-area-inset-top,0px)) 12px 8px;background:var(--panel);border-bottom:1px solid var(--border);flex:none;user-select:none}
1089
- header .title{font-weight:600;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1090
- #msgs{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:12px;display:flex;flex-direction:column;gap:8px;scroll-behavior:smooth;touch-action:pan-y}
1091
- .msg{max-width:84%;padding:8px 12px;border-radius:16px;font-size:15px;white-space:pre-wrap;word-break:break-word;contain:layout paint;animation:pop .16s ease-out}
1092
- @keyframes pop{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
1093
- .msg.user{align-self:flex-end;background:#1f3a5f;border:1px solid #2c5288;border-bottom-right-radius:5px}
1094
- .msg.ai{align-self:flex-start;background:var(--panel);border:1px solid var(--border);border-bottom-left-radius:5px}
1095
- .msg.err{align-self:flex-start;background:#3a1d21;border:1px solid #5c2b30;color:#ff9d9d}
1096
- .msg .meta{display:block;font-size:10.5px;color:var(--muted);margin-top:5px;user-select:none}
1097
- .msg img.ph,.msg video.ph{display:block;max-width:230px;max-height:230px;border-radius:12px;margin:5px 0;cursor:zoom-in;border:1px solid var(--border);background:#000}
1098
- #composer{padding:8px 10px calc(8px + var(--sab));background:var(--panel);border-top:1px solid var(--border);flex:none;user-select:none}
1099
- #previews{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px}
1100
- #previews:empty{display:none}
1101
- #previews .pv{position:relative}
1102
- #previews img,#previews video{width:50px;height:50px;object-fit:cover;border-radius:9px;border:1px solid var(--border)}
1103
- #previews button{position:absolute;top:-7px;right:-7px;background:var(--red);color:#fff;border:none;border-radius:50%;width:19px;height:19px;font-size:11px;line-height:1;cursor:pointer}
1104
- #inputrow{display:flex;gap:7px;align-items:flex-end}
1105
- #text{flex:1;max-height:130px;font-size:16px;padding:10px 12px;border-radius:20px}
1106
- .iconbtn{background:var(--panel2);border:1px solid var(--border);color:var(--text);border-radius:50%;width:42px;height:42px;font-size:18px;cursor:pointer;flex:none;touch-action:manipulation}
1107
- .iconbtn:active{transform:scale(.94)}
1108
- .iconbtn.send{background:var(--accent);border-color:var(--accent)}
1109
- #drawer{position:fixed;inset:0;background:rgba(0,0,0,.55);opacity:0;pointer-events:none;transition:opacity .18s;z-index:30}
1110
- #drawer.open{opacity:1;pointer-events:auto}
1111
- #drawer .panel{position:absolute;left:0;top:0;bottom:0;width:min(330px,88%);background:var(--panel);border-right:1px solid var(--border);padding:calc(12px + env(safe-area-inset-top,0px)) 14px calc(12px + var(--sab));overflow-y:auto;transform:translateX(-100%);transition:transform .2s cubic-bezier(.2,.8,.2,1)}
1112
- #drawer.open .panel{transform:none}
1113
- .sitem{padding:9px 10px;border-radius:10px;cursor:pointer;display:flex;gap:8px;align-items:center;border:1px solid transparent;user-select:none}
1114
- .sitem.active{background:var(--panel2);border-color:var(--border)}
1115
- .sitem .t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px}
1116
- .sec-h{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin:14px 0 8px}
1117
- #lightbox{position:fixed;inset:0;background:rgba(0,0,0,.92);display:none;z-index:40;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:16px}
1118
- #lightbox.open{display:flex}
1119
- #lightbox img,#lightbox video{max-width:96%;max-height:80%;border-radius:10px}
1120
- .caret{display:inline-block;width:7px;height:14px;background:var(--muted);animation:bl 1s steps(2) infinite;vertical-align:-2px}
1121
- @keyframes bl{50%{opacity:0}}
1122
- #qrbox img{width:220px;height:220px;background:#fff;border-radius:12px;padding:8px;image-rendering:pixelated}
1123
- </style></head><body>
1124
- <header>
1125
- <button class="iconbtn" id="btnDrawer" aria-label="Меню">&#9776;</button>
1126
- <div class="title">{{ env_name }}</div>
1127
- <span class="badge free" id="modelBadge">{{ model_name }}</span>
1128
- <span class="badge">{{ effort }}</span>
1129
- <span class="badge" id="waBadge" style="display:none"></span>
1130
- {% if pwd_enabled %}<a class="iconbtn" style="text-align:center;line-height:40px" href="/c/{{ env_id }}/logout">&#8629;</a>{% endif %}
1131
- </header>
1132
-
1133
- <div id="drawer"><div class="panel">
1134
- <div class="row" style="margin-bottom:8px">
1135
- <button class="btn" id="btnNew" style="flex:1">+ Новый чат</button>
1136
- <button class="iconbtn" id="btnClose">&#10005;</button></div>
1137
- <div class="sec-h">Сессии</div><div id="slist"></div>
1138
- <div class="sec-h">WhatsApp <span id="waDot" class="dot off"></span><span id="waStatus" class="muted">…</span></div>
1139
- <div id="waPanel"></div>
1140
- </div></div>
1141
-
1142
- <div id="msgs"></div>
1143
-
1144
- <div id="composer">
1145
- <div id="previews"></div>
1146
- <div id="inputrow">
1147
- <input type="file" id="file" accept="image/*,video/*" multiple hidden>
1148
- <button class="iconbtn" id="btnAttach" aria-label="Медиа">&#128247;</button>
1149
- <textarea id="text" rows="1" placeholder="Сообщение…" enterkeyhint="send"></textarea>
1150
- <button class="iconbtn send" id="btnSend" aria-label="Отправить">&#10148;</button>
1151
- </div></div>
1152
-
1153
- <div id="lightbox"><div id="lbMedia"></div>
1154
- <div class="row"><a class="btn" id="lbDl" href="#" download>Скачать</a>
1155
- <button class="btn sec" id="lbClose">Закрыть</button></div></div>
1156
-
1157
- <script>
1158
- var ENV_ID = {{ env_id | tojson }};
1159
- var IS_MOBILE = {{ is_mobile | tojson }};
1160
- var sessions = [], curSid = null, pending = [], busy = false, cursor = 0, pollT = null;
1161
- var $ = function(i){ return document.getElementById(i); };
1162
- var msgsEl = $('msgs');
1163
-
1164
- function esc(s){ var d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }
1165
- function api(p, o){ o = o || {}; return fetch('/c/' + ENV_ID + '/api' + p, o).then(function(r){ return r.json(); }); }
1166
- function vibrate(ms){ if (navigator.vibrate) try { navigator.vibrate(ms); } catch(e){} }
1167
-
1168
- function mediaEl(name, kind, big){
1169
- var url = '/files/' + ENV_ID + '/' + encodeURIComponent(name);
1170
- var el;
1171
- if (kind === 'video'){ el = document.createElement('video'); el.className = 'ph'; el.controls = true; el.playsInline = true; el.preload = 'metadata'; el.src = url; }
1172
- else { el = document.createElement('img'); el.className = 'ph'; el.loading = 'lazy'; el.decoding = 'async'; el.src = url; }
1173
- el.onclick = function(){ openLightbox(name, kind); };
1174
- return el;
1175
- }
1176
- function openLightbox(name, kind){
1177
- var lb = $('lightbox'), box = $('lbMedia'); box.innerHTML = '';
1178
- var url = '/files/' + ENV_ID + '/' + encodeURIComponent(name);
1179
- var el = mediaEl(name, kind, true); el.style.cursor = 'default'; el.onclick = null;
1180
- if (kind === 'video'){ el.controls = true; }
1181
- box.appendChild(el);
1182
- $('lbDl').href = url + '?download=1'; $('lbDl').setAttribute('download', name);
1183
- lb.classList.add('open');
1184
- }
1185
- $('lbClose').onclick = function(){ $('lightbox').classList.remove('open'); $('lbMedia').innerHTML = ''; };
1186
-
1187
- function appendMsg(m){
1188
- var d = document.createElement('div');
1189
- d.className = 'msg ' + (m.error ? 'err' : (m.role === 'user' ? 'user' : 'ai'));
1190
- (m.media || []).forEach(function(md){ d.appendChild(mediaEl(md.n, md.k)); });
1191
- if (m.text){ var t = document.createElement('span'); t.textContent = m.text; d.appendChild(t); }
1192
- if (m.model || m.ts){ var meta = document.createElement('span'); meta.className = 'meta';
1193
- meta.textContent = (m.model || '') + (m.switched ? ' · фото-режим' : '') + (m.ts ? ' · ' + m.ts.slice(11, 16) : '');
1194
- d.appendChild(meta); }
1195
- msgsEl.appendChild(d);
1196
- return d;
1197
- }
1198
- function scrollBottom(){ msgsEl.scrollTop = msgsEl.scrollHeight; }
1199
-
1200
- var liveEl = null;
1201
- function setLive(live){
1202
- if (!live || (!live.text && !live.busy)){ if (liveEl){ liveEl.remove(); liveEl = null; } return; }
1203
- if (!liveEl){ liveEl = document.createElement('div'); liveEl.className = 'msg ai'; msgsEl.appendChild(liveEl); }
1204
- liveEl.textContent = live.text || '';
1205
- if (live.busy){ var c = document.createElement('span'); c.className = 'caret'; liveEl.appendChild(c); }
1206
- scrollBottom();
1207
- }
1208
- function refresh(){
1209
- if (!curSid){ setLive(null); schedule(4000); return; }
1210
- api('/sessions/' + curSid + '/poll?after=' + cursor).then(function(d){
1211
- if (!d.ok){ schedule(5000); return; }
1212
- (d.messages || []).forEach(function(m){ appendMsg(m); cursor++; });
1213
- if (d.messages && d.messages.length) scrollBottom();
1214
- busy = !!d.busy;
1215
- setLive(d.live);
1216
- if (d.model_name) $('modelBadge').textContent = d.model_name;
1217
- schedule(busy || d.live ? 1400 : 3500);
1218
- }).catch(function(){ schedule(5000); });
1219
- }
1220
- function schedule(ms){ if (pollT) clearTimeout(pollT); pollT = setTimeout(refresh, ms); }
1221
-
1222
- function loadSessions(cb){
1223
- api('/sessions').then(function(d){
1224
- sessions = d.sessions || [];
1225
- if (curSid && !sessions.some(function(s){ return s.id === curSid; })){ curSid = null; cursor = 0; msgsEl.innerHTML = ''; }
1226
- if (!curSid && sessions.length){ curSid = sessions[0].id; cursor = 0; msgsEl.innerHTML = ''; }
1227
- renderSessions(); refresh(); if (cb) cb();
1228
- });
1229
- }
1230
- function renderSessions(){
1231
- var el = $('slist'); el.innerHTML = '';
1232
- sessions.forEach(function(s){
1233
- var d = document.createElement('div');
1234
- d.className = 'sitem' + (s.id === curSid ? ' active' : '');
1235
- var t = document.createElement('span'); t.className = 't'; t.textContent = s.title;
1236
- var x = document.createElement('button'); x.className = 'btn danger small'; x.textContent = '✕';
1237
- x.onclick = function(ev){ ev.stopPropagation(); if (confirm('Удалить сессию?')) api('/sessions/' + s.id + '/delete', {method:'POST'}).then(loadSessions); };
1238
- d.onclick = function(){ curSid = s.id; cursor = 0; msgsEl.innerHTML = ''; $('drawer').classList.remove('open'); renderSessions(); refresh(); };
1239
- d.appendChild(t); d.appendChild(x); el.appendChild(d);
1240
- });
1241
- if (!sessions.length) el.innerHTML = '<p class="muted">Нет сессий.</p>';
1242
- }
1243
- function sendMsg(){
1244
- var text = $('text').value.trim();
1245
- if (!text && !pending.length) return;
1246
- var media = pending.slice();
1247
- $('text').value = ''; $('text').style.height = 'auto'; pending = []; renderPreviews();
1248
- var node = appendMsg({role:'user', text:text, media:media, ts:new Date().toISOString().slice(0,16).replace('T',' ')});
1249
- scrollBottom(); vibrate(12);
1250
- busy = true;
1251
- api('/sessions/' + (curSid || 'new') + '/send', {method:'POST', headers:{'Content-Type':'application/json'},
1252
- body: JSON.stringify({text:text, media:media, session_id:curSid})}).then(function(d){
1253
- if (!d.ok){ appendMsg({role:'assistant', text:d.error || 'Ошибка отправки', error:true}); busy = false; schedule(2500); return; }
1254
- if (d.session_id !== curSid){ curSid = d.session_id; cursor = 0; msgsEl.innerHTML = ''; loadSessions(); }
1255
- else schedule(1200);
1256
- });
1257
- }
1258
- function renderPreviews(){
1259
- var el = $('previews'); el.innerHTML = '';
1260
- pending.forEach(function(md, i){
1261
- var w = document.createElement('div'); w.className = 'pv';
1262
- var el2 = mediaEl(md.n, md.k); el2.onclick = null; el2.style.cursor = 'default';
1263
- if (md.k === 'video') el2.controls = false;
1264
- var x = document.createElement('button'); x.textContent = '✕';
1265
- x.onclick = function(){ pending.splice(i, 1); renderPreviews(); };
1266
- w.appendChild(el2); w.appendChild(x); el.appendChild(w);
1267
- });
1268
- }
1269
- function uploadFiles(files){
1270
- Array.prototype.slice.call(files).forEach(function(f){
1271
- var fd = new FormData(); fd.append('file', f);
1272
- api('/upload', {method:'POST', body: fd}).then(function(d){
1273
- if (d.ok){ pending.push({n:d.filename, k:d.kind}); renderPreviews(); }
1274
- else alert(d.error || 'Ошибка загрузки');
1275
- });
1276
- });
1277
- }
1278
- $('btnSend').onclick = sendMsg;
1279
- $('text').addEventListener('keydown', function(e){ if (e.key === 'Enter' && !e.shiftKey){ e.preventDefault(); sendMsg(); } });
1280
- $('text').addEventListener('input', function(){ this.style.height = 'auto'; this.style.height = Math.min(this.scrollHeight, 130) + 'px'; });
1281
- $('btnAttach').onclick = function(){ $('file').click(); };
1282
- $('file').onchange = function(){ uploadFiles(this.files); this.value = ''; };
1283
- $('btnDrawer').onclick = function(){ $('drawer').classList.add('open'); waRefresh(); };
1284
- $('btnClose').onclick = function(){ $('drawer').classList.remove('open'); };
1285
- $('drawer').onclick = function(e){ if (e.target === this) this.classList.remove('open'); };
1286
- $('btnNew').onclick = function(){ api('/sessions/new', {method:'POST'}).then(function(d){ curSid = d.session_id; cursor = 0; msgsEl.innerHTML = ''; $('drawer').classList.remove('open'); loadSessions(); }); };
1287
-
1288
- /* ---------------- WhatsApp panel ---------------- */
1289
- var waTimer = null;
1290
- function waBadge(status){
1291
- var b = $('waBadge'), dot = $('waDot'), st = $('waStatus');
1292
- var map = {connected:['on','WA подключён'], qr:['qr','WA: QR готов'], connecting:['qr','WA: подключение'], stored:['off','WA: сохранён'], disconnected:['off','WA: отключён'], logged_out:['off','WA: выход'], disabled:['off',''], bridge_offline:['off','WA: мост офлайн']};
1293
- var m = map[status] || ['off', status || ''];
1294
- dot.className = 'dot ' + m[0]; st.textContent = m[1];
1295
- if (status === 'connected'){ b.style.display = ''; b.textContent = 'WA'; } else b.style.display = 'none';
1296
- }
1297
- function waRefresh(){
1298
- api('/wa/status').then(function(d){
1299
- waBadge(d.status);
1300
- renderWaPanel(d);
1301
- }).catch(function(){});
1302
- }
1303
- function renderWaPanel(d){
1304
- var p = $('waPanel'); p.innerHTML = '';
1305
- if (d.status === 'disabled' || d.status === 'bridge_offline'){
1306
- p.innerHTML = '<p class="muted">WhatsApp-коннектор выключен на сервере.</p>'; return;
1307
- }
1308
- if (d.status === 'connected'){
1309
- var num = (d.info && (d.info.id || '')) ? String(d.info.id).split(':')[0] : '';
1310
- p.innerHTML = '<p class="muted" style="margin-bottom:8px">Подключён: <b>' + esc(d.info && d.info.name || '') + '</b> +' + esc(num) + '<br>Повторно сканировать не нужно — соединение привязано к среде.</p>' +
1311
- '<label class="muted row" style="margin-bottom:6px"><input type="checkbox" id="waAr" ' + (d.auto_reply ? 'checked' : '') + '> авто-ответчик агента</label>' +
1312
- '<div class="row" style="margin-bottom:8px"><input type="text" id="waTo" placeholder="+7777... или jid" style="flex:1"><button class="btn small" id="waSendBtn">Отправить в WA</button></div>' +
1313
- '<textarea id="waText" rows="2" placeholder="Текст сообщения в WhatsApp"></textarea>' +
1314
- '<div class="row" style="margin-top:8px"><button class="btn sec small" id="waHist">История WA</button>' +
1315
- '<button class="btn danger small" id="waOut">Разорвать соединение</button></div><div id="waHistBox"></div>';
1316
- $('waAr').onchange = function(){ api('/wa/settings', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({auto_reply:this.checked})}); };
1317
- $('waSendBtn').onclick = function(){ api('/wa/send', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({to:$('waTo').value, text:$('waText').value})}).then(function(r){ if(!r.ok) alert(r.error||'Ошибка'); else { $('waText').value=''; vibrate(12); } }); };
1318
- $('waOut').onclick = function(){ if (confirm('Разорвать соединение с WhatsApp?')) api('/wa/logout', {method:'POST'}).then(waRefresh); };
1319
- $('waHist').onclick = function(){ api('/wa/history').then(function(h){ var box = $('waHistBox');
1320
- box.innerHTML = '<div class="sec-h">Последние сообщения</div>' + (h.history || []).slice(-15).reverse().map(function(r){
1321
- return '<p class="muted" style="margin-bottom:4px">' + (r.fromMe ? '➤' : '◀') + ' <b>' + esc(r.pushName || r.jid.split('@')[0]) + ':</b> ' + esc((r.text || '').slice(0, 80)) + '</p>'; }).join(''); }); };
1322
- return;
1323
- }
1324
- if (IS_MOBILE){
1325
- p.innerHTML = '<div class="card" style="margin:0;background:#2a2413;border-color:#5c4a1e"><p style="font-size:13px">📵 <b>Авторизация WhatsApp возможна только с компьютера.</b><br><span class="muted">Откройте эту среду на компьютере (десктоп-браузер), нажмите «Подключить WhatsApp» и отсканируйте QR телефоном. С телефона соединение не создаётся.</span></p></div>';
1326
- return;
1327
- }
1328
- p.innerHTML = '<div class="row"><button class="btn" id="waConn">Подключить WhatsApp</button></div>' +
1329
- '<div id="qrbox" style="margin-top:10px"></div><p class="muted" style="margin-top:8px">QR появится здесь; отсканируйте его телефоном: WhatsApp → Настройки → Связанные устройства. Соединение одноразовое и привязывается к среде.</p>';
1330
- $('waConn').onclick = function(){ api('/wa/start', {method:'POST'}).then(waLoop); };
1331
- if (d.status === 'qr') waLoop();
1332
- }
1333
- function waLoop(){
1334
- if (waTimer) clearInterval(waTimer);
1335
- var tries = 0;
1336
- waTimer = setInterval(function(){
1337
- tries++;
1338
- api('/wa/status').then(function(d){
1339
- waBadge(d.status);
1340
- if (d.status === 'connected' || d.status === 'logged_out'){ clearInterval(waTimer); waTimer = null; renderWaPanel(d); return; }
1341
- if (d.status === 'qr'){
1342
- api('/wa/qr.png?r=' + tries).then(function(){ }).catch(function(){});
1343
- var box = $('qrbox');
1344
- if (box){ box.innerHTML = '<img src="/c/' + ENV_ID + '/api/wa/qr.png?r=' + tries + '" alt="QR WhatsApp">'; }
1345
- }
1346
- if (tries > 150){ clearInterval(waTimer); waTimer = null; }
1347
- });
1348
- }, 2500);
1349
- }
1350
- setInterval(function(){ if (!$('drawer').classList.contains('open')) api('/wa/status').then(function(d){ waBadge(d.status); }).catch(function(){}); }, 15000);
1351
- loadSessions(); waRefresh();
1352
- </script></body></html>"""
1353
-
1354
-
1355
- # ---------------------------------------------------------------------------
1356
- # Админ /admhosto
1357
- # ---------------------------------------------------------------------------
1358
- def admin_auth_ok():
1359
- pwd = os.environ.get("ADMIN_PASSWORD", "").strip()
1360
- if not pwd:
1361
- return True
1362
- return bool(session.get('adm_auth'))
1363
-
1364
-
1365
- @app.route('/')
1366
- def index():
1367
- return redirect(url_for('admhosto'))
1368
-
1369
-
1370
- @app.route('/healthz')
1371
- def healthz():
1372
- with oc_lock:
1373
- st = {'oc_ready': MODELS_STATE['ready'], 'default_model': (MODELS_STATE['default'] or {}).get('name')}
1374
- st.update({'repo': REPO_ID, 'wa': wa_available(), 'ts': get_ts()})
1375
- return jsonify(st)
1376
-
1377
-
1378
- @app.route('/manifest.json')
1379
- def manifest():
1380
- return jsonify({
1381
- 'name': 'OpenCode Hosting', 'short_name': 'Chat',
1382
- 'start_url': '/', 'display': 'standalone',
1383
- 'background_color': '#0b0d10', 'theme_color': '#0b0d10',
1384
- 'icons': [{'src': '/icon.png', 'sizes': '512x512', 'type': 'image/png'}],
1385
- })
1386
-
1387
-
1388
- _ICON_CACHE = {}
1389
-
1390
-
1391
- @app.route('/icon.png')
1392
- def icon_png():
1393
- if 'png' not in _ICON_CACHE:
1394
- img = Image.new('RGB', (512, 512), (15, 18, 23))
1395
- d = Image.Draw(img) if hasattr(Image, 'Draw') else None
1396
- try:
1397
- from PIL import ImageDraw
1398
- dr = ImageDraw.Draw(img)
1399
- dr.rounded_rectangle([96, 120, 416, 340], radius=60, fill=(79, 156, 249))
1400
- dr.polygon([(150, 330), (150, 410), (240, 330)], fill=(79, 156, 249))
1401
- dr.ellipse([170, 200, 210, 240], fill=(255, 255, 255))
1402
- dr.ellipse([236, 200, 276, 240], fill=(255, 255, 255))
1403
- dr.ellipse([302, 200, 342, 240], fill=(255, 255, 255))
1404
- except Exception:
1405
- pass
1406
- buf = io.BytesIO()
1407
- img.save(buf, format='PNG')
1408
- _ICON_CACHE['png'] = buf.getvalue()
1409
- return Response(_ICON_CACHE['png'], mimetype='image/png')
1410
-
1411
-
1412
- @app.route('/admhosto/login', methods=['GET', 'POST'])
1413
- def adm_login():
1414
- if not os.environ.get("ADMIN_PASSWORD", "").strip():
1415
- return redirect(url_for('admhosto'))
1416
- if request.method == 'POST':
1417
- if request.form.get('password', '') == os.environ.get("ADMIN_PASSWORD", "").strip():
1418
- session.permanent = True
1419
- session['adm_auth'] = True
1420
- return redirect(url_for('admhosto'))
1421
- flash('Неверный пароль', 'error')
1422
- return render_cached(LOGIN_TEMPLATE, title='Вход в админ-панель', subtitle='/admhosto')
1423
-
1424
-
1425
- @app.route('/admhosto/logout')
1426
- def adm_logout():
1427
- session.pop('adm_auth', None)
1428
- return redirect(url_for('adm_login'))
1429
-
1430
-
1431
- def _media_count(env_id):
1432
- d = os.path.join(MEDIA_DIR, env_id)
1433
- try:
1434
- return len([f for f in os.listdir(d) if MEDIA_NAME_RE.match(f)])
1435
- except OSError:
1436
- return 0
1437
-
1438
-
1439
- @app.route('/admhosto', methods=['GET'])
1440
- def admhosto():
1441
- if not admin_auth_ok():
1442
- return redirect(url_for('adm_login'))
1443
- active_envs, archived_envs = [], []
1444
- for env_id in list_all_env_ids():
1445
- env_data = get_env_data(env_id)
1446
- if not env_data:
1447
- continue
1448
- s = env_data.get('settings', {})
1449
- info = {'id': env_id, 'name': s.get('name', f'Среда {env_id}'),
1450
- 'pwd_enabled': s.get('client_password_enabled', False),
1451
- 'password': s.get('client_password', ''),
1452
- 'sessions_count': len(env_data.get('sessions', {})),
1453
- 'media_count': _media_count(env_id),
1454
- 'wa': (wa_status(env_id) or {}).get('status', 'none')}
1455
- (archived_envs if s.get('is_deleted') else active_envs).append(info)
1456
- with oc_lock:
1457
- default_name = (MODELS_STATE['default'] or {}).get('name') or 'DeepSeek V4.1 Flash Free'
1458
- free_models = MODELS_STATE['free']
1459
- return render_cached(ADMHOSTO_TEMPLATE, active_envs=active_envs, archived_envs=archived_envs,
1460
- repo_id=REPO_ID, hf_write=bool(HF_TOKEN_WRITE),
1461
- admin_pwd=bool(os.environ.get("ADMIN_PASSWORD", "").strip()),
1462
- oc_ready=MODELS_STATE['ready'], default_model_name=default_name,
1463
- effort=EFFORT, free_models=free_models, node_ok=bool(find_node()),
1464
- wa_state=('включён' if wa_available() else 'выключен'))
1465
-
1466
-
1467
- @app.route('/admhosto/create', methods=['POST'])
1468
- def create_environment():
1469
- if not admin_auth_ok():
1470
- return redirect(url_for('adm_login'))
1471
- existing = set(list_all_env_ids())
1472
- while True:
1473
- new_id = ''.join(random.choices(string.digits, k=6))
1474
- if new_id not in existing:
1475
- break
1476
- save_env_data(new_id, _default_env_struct(new_id))
1477
- _REMOTE_FILES_CACHE['ts'] = 0
1478
- flash(f'Среда {new_id} создана. Ссылка: /c/{new_id}', 'success')
1479
- return redirect(url_for('admhosto'))
1480
-
1481
-
1482
- @app.route('/admhosto/rename/<env_id>', methods=['POST'])
1483
- def rename_environment(env_id):
1484
- if not admin_auth_ok():
1485
- return redirect(url_for('adm_login'))
1486
- env_data = get_env_data(env_id)
1487
- if env_data:
1488
- env_data['settings']['name'] = request.form.get('name', '').strip() or f'Среда {env_id}'
1489
- save_env_data(env_id, env_data)
1490
- flash('Имя обновлено.', 'success')
1491
- return redirect(url_for('admhosto'))
1492
-
1493
-
1494
- @app.route('/admhosto/update_pwd/<env_id>', methods=['POST'])
1495
- def update_env_pwd(env_id):
1496
- if not admin_auth_ok():
1497
- return redirect(url_for('adm_login'))
1498
- env_data = get_env_data(env_id)
1499
- if env_data:
1500
- env_data['settings']['client_password_enabled'] = 'pwd_enabled' in request.form
1501
- env_data['settings']['client_password'] = request.form.get('password', '').strip()
1502
- save_env_data(env_id, env_data)
1503
- flash('Пароль клиента обновлён.', 'success')
1504
- return redirect(url_for('admhosto'))
1505
-
1506
-
1507
- @app.route('/admhosto/clear_history/<env_id>', methods=['POST'])
1508
- def clear_history(env_id):
1509
- if not admin_auth_ok():
1510
- return redirect(url_for('adm_login'))
1511
- env_data = get_env_data(env_id)
1512
- if env_data:
1513
- for s in env_data.get('sessions', {}).values():
1514
- if s.get('oc_sid'):
1515
- try:
1516
- requests.delete(f'{OC_BASE}/session/{s["oc_sid"]}', timeout=10)
1517
- except Exception:
1518
- pass
1519
- s.update(messages=[], saved_assistant_ids=[], oc_sid=None)
1520
- env_data['wa_log'] = []
1521
- save_env_data(env_id, env_data)
1522
- mark_state_dirty()
1523
- flash('История очищена.', 'success')
1524
- return redirect(url_for('admhosto'))
1525
-
1526
-
1527
- @app.route('/admhosto/delete/<env_id>', methods=['POST'])
1528
- def delete_environment(env_id):
1529
- if not admin_auth_ok():
1530
- return redirect(url_for('adm_login'))
1531
- env_data = get_env_data(env_id)
1532
- if env_data:
1533
- env_data['settings']['is_deleted'] = True
1534
- save_env_data(env_id, env_data)
1535
- flash('Среда в архиве.', 'success')
1536
- return redirect(url_for('admhosto'))
1537
-
1538
-
1539
- @app.route('/admhosto/restore/<env_id>', methods=['POST'])
1540
- def restore_environment(env_id):
1541
- if not admin_auth_ok():
1542
- return redirect(url_for('adm_login'))
1543
- env_data = get_env_data(env_id)
1544
- if env_data:
1545
- env_data['settings']['is_deleted'] = False
1546
- save_env_data(env_id, env_data)
1547
- flash('Среда восстановлена.', 'success')
1548
- return redirect(url_for('admhosto'))
1549
-
1550
-
1551
- @app.route('/admhosto/hard_delete/<env_id>', methods=['POST'])
1552
- def hard_delete_environment(env_id):
1553
- if not admin_auth_ok():
1554
- return redirect(url_for('adm_login'))
1555
- path = _env_file_path(env_id)
1556
- with data_lock:
1557
- _ENV_CACHE.pop(env_id, None)
1558
- try:
1559
- if os.path.exists(path):
1560
- os.remove(path)
1561
- except Exception:
1562
- pass
1563
- delete_file_in_hf(f'{DATA_DIR}/{env_id}.json')
1564
- try:
1565
- if HF_TOKEN_WRITE and REPO_ID:
1566
- HfApi().delete_folder(folder_path=f'{MEDIA_DIR}/{env_id}', repo_id=REPO_ID,
1567
- repo_type='dataset', token=HF_TOKEN_WRITE,
1568
- commit_message=f'Delete media {env_id}')
1569
- except Exception:
1570
- pass
1571
- wb_post('/logout', {'env': env_id})
1572
- _REMOTE_FILES_CACHE['ts'] = 0
1573
- flash('Среда удалена навсегда.', 'success')
1574
- return redirect(url_for('admhosto'))
1575
-
1576
-
1577
- @app.route('/admhosto/wa_logout/<env_id>', methods=['POST'])
1578
- def adm_wa_logout(env_id):
1579
- if not admin_auth_ok():
1580
- return redirect(url_for('adm_login'))
1581
- wb_post('/logout', {'env': env_id})
1582
- mark_state_dirty()
1583
- flash(f'WhatsApp среды {env_id} отключён.', 'success')
1584
- return redirect(url_for('admhosto'))
1585
-
1586
-
1587
- @app.route('/admhosto/media/<env_id>')
1588
- def media_gallery(env_id):
1589
- if not admin_auth_ok():
1590
- return redirect(url_for('adm_login'))
1591
- d = os.path.join(MEDIA_DIR, env_id)
1592
- items = []
1593
- try:
1594
- for f in sorted(os.listdir(d), key=lambda x: os.path.getmtime(os.path.join(d, x)), reverse=True):
1595
- if not MEDIA_NAME_RE.match(f):
1596
- continue
1597
- sz = os.path.getsize(os.path.join(d, f))
1598
- items.append({'name': f, 'kind': 'video' if f.rsplit('.', 1)[-1] in ('mp4', 'webm', 'mov', 'mpg', 'mkv', '3gp') else 'image',
1599
- 'size': f'{sz / 1024 / 1024:.1f} MB' if sz > 1024 * 1024 else f'{sz // 1024} KB'})
1600
- except OSError:
1601
- pass
1602
- return render_cached(MEDIA_TEMPLATE, env_id=env_id, items=items)
1603
-
1604
-
1605
- @app.route('/admhosto/media/<env_id>/zip')
1606
- def media_zip(env_id):
1607
- if not admin_auth_ok():
1608
- return redirect(url_for('adm_login'))
1609
- d = os.path.join(MEDIA_DIR, env_id)
1610
- buf = io.BytesIO()
1611
- with zipfile.ZipFile(buf, 'w', zipfile.ZIP_STORED) as z:
1612
- try:
1613
- for f in os.listdir(d):
1614
- if MEDIA_NAME_RE.match(f):
1615
- z.write(os.path.join(d, f), arcname=f)
1616
- except OSError:
1617
- pass
1618
- buf.seek(0)
1619
- return send_file(buf, mimetype='application/zip', as_attachment=True,
1620
- download_name=f'media_{env_id}.zip')
1621
-
1622
-
1623
- # ---------------------------------------------------------------------------
1624
- # Файлы медиа
1625
- # ---------------------------------------------------------------------------
1626
- @app.route('/files/<env_id>/<filename>')
1627
- def serve_file(env_id, filename):
1628
- filename = os.path.basename(filename)
1629
- if not MEDIA_NAME_RE.match(filename):
1630
- return Response('bad name', status=400)
1631
- local = ensure_media_local(env_id, filename)
1632
- if not local:
1633
- return Response('not found', status=404)
1634
- resp = send_from_directory(os.path.join(MEDIA_DIR, env_id), filename,
1635
- max_age=60 * 60 * 24 * 30)
1636
- if request.args.get('download'):
1637
- resp.headers['Content-Disposition'] = f'attachment; filename="{filename}"'
1638
- return resp
1639
-
1640
-
1641
- # ---------------------------------------------------------------------------
1642
- # Клиент /c/<env_id>
1643
- # ---------------------------------------------------------------------------
1644
- def client_auth_ok(env_id, env_data):
1645
- s = env_data.get('settings', {})
1646
- if not s.get('client_password_enabled'):
1647
- return True
1648
- return bool(session.get(f'client_auth_{env_id}'))
1649
-
1650
-
1651
- @app.route('/c/<env_id>')
1652
- def client_chat(env_id):
1653
- env_data = get_env_data(env_id)
1654
- if not env_data:
1655
- return render_cached(DEAD_TEMPLATE, title='Среда не найдена', subtitle='Проверьте ссылку.'), 404
1656
- if env_data['settings'].get('is_deleted'):
1657
- return render_cached(DEAD_TEMPLATE, title='Среда отключена', subtitle='Обратитесь к администратору.'), 403
1658
- if not client_auth_ok(env_id, env_data):
1659
- return redirect(url_for('client_login', env_id=env_id))
1660
- with oc_lock:
1661
- default = MODELS_STATE['default']
1662
- model_name = (env_data['settings'].get('default_model') or default or {}).get('name') or 'DeepSeek V4.1 Flash Free'
1663
- return render_cached(CHAT_TEMPLATE, env_id=env_id,
1664
- env_name=env_data['settings'].get('name', f'Среда {env_id}'),
1665
- model_name=model_name, effort=EFFORT,
1666
- pwd_enabled=bool(env_data['settings'].get('client_password_enabled')),
1667
- is_mobile=is_mobile_request())
1668
-
1669
-
1670
- @app.route('/c/<env_id>/login', methods=['GET', 'POST'])
1671
- def client_login(env_id):
1672
- env_data = get_env_data(env_id)
1673
- if not env_data or env_data['settings'].get('is_deleted'):
1674
- return render_cached(DEAD_TEMPLATE, title='Среда недоступна', subtitle=''), 404
1675
- if request.method == 'POST':
1676
- if request.form.get('password', '') == env_data['settings'].get('client_password', ''):
1677
- session.permanent = True
1678
- session[f'client_auth_{env_id}'] = True
1679
- return redirect(url_for('client_chat', env_id=env_id))
1680
- flash('Неверный пароль', 'error')
1681
- return render_cached(LOGIN_TEMPLATE, title=env_data['settings'].get('name', env_id),
1682
- subtitle='Пароль доступа к среде')
1683
-
1684
-
1685
- @app.route('/c/<env_id>/logout')
1686
- def client_logout(env_id):
1687
- session.pop(f'client_auth_{env_id}', None)
1688
- return redirect(url_for('client_login', env_id=env_id))
1689
-
1690
-
1691
- def _client_guard(env_id):
1692
- env_data = get_env_data(env_id)
1693
- if not env_data or env_data['settings'].get('is_deleted'):
1694
- return None, jsonify({'ok': False, 'error': 'Среда недоступна'})
1695
- if not client_auth_ok(env_id, env_data):
1696
- return None, jsonify({'ok': False, 'error': 'Нет доступа'})
1697
- return env_data, None
1698
-
1699
-
1700
- @app.route('/c/<env_id>/api/sessions')
1701
- def api_sessions(env_id):
1702
- env_data, err = _client_guard(env_id)
1703
- if err:
1704
- return err
1705
- out = [{'id': sid, 'title': env_data['sessions'][sid].get('title', 'Чат'),
1706
- 'created_at': env_data['sessions'][sid].get('created_at', '')}
1707
- for sid in env_data.get('session_order', []) if sid in env_data['sessions']]
1708
- return jsonify({'ok': True, 'sessions': out})
1709
-
1710
-
1711
- @app.route('/c/<env_id>/api/sessions/new', methods=['POST'])
1712
- def api_new_session(env_id):
1713
- env_data, err = _client_guard(env_id)
1714
- if err:
1715
- return err
1716
- sid = uuid4().hex[:12]
1717
- env_data['sessions'][sid] = {'id': sid, 'title': f'Чат {len(env_data["sessions"]) + 1}',
1718
- 'created_at': get_ts(), 'oc_sid': None,
1719
- 'saved_assistant_ids': [], 'messages': []}
1720
- env_data['session_order'].insert(0, sid)
1721
- save_env_data(env_id, env_data)
1722
- return jsonify({'ok': True, 'session_id': sid})
1723
-
1724
-
1725
- @app.route('/c/<env_id>/api/sessions/<sid>/delete', methods=['POST'])
1726
- def api_delete_session(env_id, sid):
1727
- env_data, err = _client_guard(env_id)
1728
- if err:
1729
- return err
1730
- s = env_data['sessions'].pop(sid, None)
1731
- if s and s.get('oc_sid'):
1732
- try:
1733
- requests.delete(f'{OC_BASE}/session/{s["oc_sid"]}', timeout=10)
1734
- except Exception:
1735
- pass
1736
- if sid in env_data.get('session_order', []):
1737
- env_data['session_order'].remove(sid)
1738
- if env_data['settings'].get('wa_oc_sid') == sid:
1739
- env_data['settings']['wa_oc_sid'] = None
1740
- save_env_data(env_id, env_data)
1741
- mark_state_dirty()
1742
- return jsonify({'ok': True})
1743
-
1744
-
1745
- @app.route('/c/<env_id>/api/upload', methods=['POST'])
1746
- def api_upload(env_id):
1747
- env_data, err = _client_guard(env_id)
1748
- if err:
1749
- return err
1750
- f = request.files.get('file') or request.files.get('photo')
1751
- if not f:
1752
- return jsonify({'ok': False, 'error': 'Нет файла'})
1753
- name, kind = save_incoming_media(f, env_id)
1754
- if not name:
1755
- return jsonify({'ok': False, 'error': 'Не удалось обработать файл (фото/видео?)'})
1756
- return jsonify({'ok': True, 'filename': name, 'kind': kind, 'url': f'/files/{env_id}/{name}'})
1757
-
1758
-
1759
- # --- команды /wa в чате -------------------------------------------------------
1760
- def wa_command(env_id, env_data, text):
1761
- """Возвращает текст ответа ассистента или None, если это не команда."""
1762
- if not text.startswith('/wa'):
1763
- return None
1764
- parts = text.split(None, 2)
1765
- cmd = (parts[1] if len(parts) > 1 else 'status').lower()
1766
- if not wa_available():
1767
- return '⚠️ WhatsApp-коннектор выключен на сервере.'
1768
- st = wa_status(env_id) or {}
1769
- status = st.get('status')
1770
- if cmd in ('status', 'статус'):
1771
- if status == 'connected':
1772
- info = st.get('info') or {}
1773
- return f'✅ WhatsApp подключён: {info.get("name", "")} +{str(info.get("id", "")).split(":")[0]}. Соединение привязано к среде.'
1774
- return f'WhatsApp: {status}. Подключить — кнопкой в меню (только с компьютера).'
1775
- if cmd == 'send':
1776
- if status != 'connected':
1777
- return '⚠️ WhatsApp не подключён к этой среде.'
1778
- rest = parts[2] if len(parts) > 2 else ''
1779
- m = re.split(r'\s+', rest, maxsplit=1)
1780
- if len(m) < 2 or not m[0]:
1781
- return 'Формат: /wa send +77777777777 текст сообщения'
1782
- r = wb_post('/send', {'env': env_id, 'jid': m[0], 'text': m[1]})
1783
- if r and r.get('ok'):
1784
- wa_append_log(env_data, {'id': 'cmd' + str(int(time.time())), 'jid': m[0], 'fromMe': True,
1785
- 'pushName': 'Вы (через агента)', 'text': m[1][:500],
1786
- 'ts': int(time.time() * 1000), 'group': False, 'media': None})
1787
- save_env_data(env_id, env_data)
1788
- return f'📨 Отправлено в WhatsApp: {m[0]}'
1789
- return f'⚠️ Ошибка отправки: {(r or {}).get("error", "мост не отвечает")}'
1790
- if cmd in ('last', 'history'):
1791
- n = 10
1792
- lines = env_data.get('wa_log', [])[-n:]
1793
- if not lines:
1794
- return 'История WhatsApp пуста.'
1795
- return '\n'.join(f'{"➤" if l.get("fromMe") else "◀"} {l.get("pushName") or l.get("jid", "").split("@")[0]}: {(l.get("text") or "")[:120]}' for l in reversed(lines))
1796
- if cmd in ('disconnect', 'logout'):
1797
- wb_post('/logout', {'env': env_id})
1798
- mark_state_dirty()
1799
- return '🔌 Соединение с WhatsApp разорвано, creds удалены.'
1800
- if cmd in ('connect', 'start'):
1801
- if is_mobile_request():
1802
- return '📵 Подключение WhatsApp с телефона невозможно: откройте среду на компьютере и авторизуйтесь там (QR).'
1803
- wb_post('/start', {'env': env_id})
1804
- return '⏳ Запускаю подключение: откройте меню среды НА КОМПЬЮТЕРЕ и отсканируйте QR.'
1805
- return 'Команды: /wa status · /wa send <номер> <текст> · /wa last · /wa disconnect · /wa connect'
1806
-
1807
-
1808
- @app.route('/c/<env_id>/api/sessions/<sid>/send', methods=['POST'])
1809
- def api_send(env_id, sid):
1810
- env_data, err = _client_guard(env_id)
1811
- if err:
1812
- return err
1813
- payload = request.get_json(silent=True) or {}
1814
- text = (payload.get('text') or '').strip()
1815
- media_in = payload.get('media') or payload.get('photos') or []
1816
- media = []
1817
- for md in media_in:
1818
- if isinstance(md, dict):
1819
- n, k = md.get('n'), md.get('k')
1820
- else:
1821
- n, k = md, 'image'
1822
- if n and MEDIA_NAME_RE.match(n):
1823
- media.append({'n': n, 'k': k if k in ('image', 'video') else 'image'})
1824
- media = media[:8]
1825
- if not text and not media:
1826
- return jsonify({'ok': False, 'error': 'Пустое сообщение'})
1827
-
1828
- s = env_data['sessions'].get(sid)
1829
- if not s:
1830
- sid = uuid4().hex[:12]
1831
- s = {'id': sid, 'title': (text[:40] or f'Чат {len(env_data["sessions"]) + 1}'),
1832
- 'created_at': get_ts(), 'oc_sid': None, 'saved_assistant_ids': [], 'messages': []}
1833
- env_data['sessions'][sid] = s
1834
- env_data['session_order'].insert(0, sid)
1835
-
1836
- # команды WhatsApp обрабатываются локально, без модели
1837
- wa_reply = wa_command(env_id, env_data, text)
1838
- if wa_reply is not None:
1839
- s['messages'].append({'role': 'user', 'text': text, 'media': media, 'ts': get_ts(), 'model': ''})
1840
- s['messages'].append({'role': 'assistant', 'text': wa_reply, 'media': [], 'ts': get_ts(), 'model': 'WhatsApp'})
1841
- if len(s['messages']) == 2 and (s.get('title') or '').startswith('Чат '):
1842
- s['title'] = text[:40] or s['title']
1843
- save_env_data(env_id, env_data)
1844
- return jsonify({'ok': True, 'session_id': sid})
1845
-
1846
- if not s.get('oc_sid'):
1847
- r = oc_post('/session', {'title': s.get('title', 'chat')[:60]})
1848
- if r is None or r.status_code not in (200, 201):
1849
- return jsonify({'ok': False, 'error': 'opencode не отвечает (см. /admhosto)'})
1850
- s['oc_sid'] = (r.json() or {}).get('id')
1851
- if not s['oc_sid']:
1852
- return jsonify({'ok': False, 'error': 'opencode не создал сессию'})
1853
- mark_state_dirty()
1854
-
1855
- images = [m for m in media if m['k'] == 'image']
1856
- videos = [m for m in media if m['k'] == 'video']
1857
- model, switched = model_for_message(env_data, bool(images))
1858
- if not model:
1859
- return jsonify({'ok': False, 'error': 'Модель не настроена: opencode offline'})
1860
-
1861
- parts = []
1862
- note = ''
1863
- if videos:
1864
- note = f'[Пользователь прикрепил видео: {len(videos)} шт. — оно сохранено в галерее среды, модель его не видит.] '
1865
- if text or note:
1866
- parts.append({'type': 'text', 'text': note + text})
1867
- for m in images[:4]:
1868
- local = ensure_media_local(env_id, m['n'])
1869
- if local:
1870
- parts.append({'type': 'file', 'mime': 'image/webp', 'filename': m['n'],
1871
- 'url': media_data_url(local)})
1872
-
1873
- r = oc_post(f'/session/{s["oc_sid"]}/prompt_async',
1874
- {'model': {'providerID': model['providerID'], 'modelID': model['modelID']}, 'parts': parts})
1875
- if r is None or r.status_code not in (200, 204):
1876
- return jsonify({'ok': False, 'error': 'opencode отклонил сообщение'})
1877
-
1878
- s['messages'].append({'role': 'user', 'text': text, 'media': media, 'ts': get_ts(),
1879
- 'model': model['name'], 'switched': switched})
1880
- if len(s['messages']) == 1 and (s.get('title') or '').startswith('Чат '):
1881
- s['title'] = text[:40] or s['title']
1882
- save_env_data(env_id, env_data)
1883
- return jsonify({'ok': True, 'session_id': sid})
1884
-
1885
-
1886
- def _oc_message_text(m):
1887
- return ''.join(p.get('text', '') for p in ((m or {}).get('parts') or []) if p.get('type') == 'text')
1888
-
1889
-
1890
- def _oc_message_done(m):
1891
- info = (m or {}).get('info') or {}
1892
- t = info.get('time') or {}
1893
- return bool(t.get('completed') or t.get('finished')) or info.get('error') is not None
1894
-
1895
-
1896
- @app.route('/c/<env_id>/api/sessions/<sid>/poll')
1897
- def api_poll(env_id, sid):
1898
- env_data, err = _client_guard(env_id)
1899
- if err:
1900
- return err
1901
- s = env_data['sessions'].get(sid)
1902
- if not s:
1903
- return jsonify({'ok': False, 'error': 'Сессия не найдена'})
1904
- after = max(0, min(int(request.args.get('after', 0) or 0), len(s['messages'])))
1905
- live, busy = None, False
1906
- oc_sid = s.get('oc_sid')
1907
- if oc_sid:
1908
- status = oc_get('/session/status') or {}
1909
- busy = ((status.get(oc_sid) or {}).get('type') == 'busy')
1910
- msgs = oc_get(f'/session/{oc_sid}/message') or []
1911
- changed = False
1912
- for m in msgs:
1913
- info = m.get('info') or {}
1914
- if info.get('role') != 'assistant':
1915
- continue
1916
- mid = info.get('id')
1917
- if not mid or mid in s.get('saved_assistant_ids', []):
1918
- continue
1919
- if _oc_message_done(m):
1920
- err_obj = info.get('error')
1921
- err_text = (err_obj.get('message') if isinstance(err_obj, dict) else str(err_obj)) if err_obj else None
1922
- s.setdefault('saved_assistant_ids', []).append(mid)
1923
- s['messages'].append({'role': 'assistant', 'text': _oc_message_text(m), 'media': [],
1924
- 'ts': get_ts(), 'error': err_text,
1925
- 'model': f"{info.get('providerID', '')}/{info.get('modelID', '')}".strip('/')})
1926
- changed = True
1927
- if changed:
1928
- save_env_data(env_id, env_data)
1929
- mark_state_dirty()
1930
- if busy and msgs and (msgs[-1].get('info') or {}).get('role') == 'assistant' and not _oc_message_done(msgs[-1]):
1931
- live = {'text': _oc_message_text(msgs[-1]), 'busy': True}
1932
- with oc_lock:
1933
- default_name = (MODELS_STATE['default'] or {}).get('name')
1934
- return jsonify({'ok': True, 'messages': s['messages'][after:], 'cursor': len(s['messages']),
1935
- 'live': live, 'busy': busy, 'model_name': default_name})
1936
-
1937
-
1938
- # --- WhatsApp API клиента -----------------------------------------------------
1939
- @app.route('/c/<env_id>/api/wa/status')
1940
- def api_wa_status(env_id):
1941
- env_data, err = _client_guard(env_id)
1942
- if err:
1943
- return err
1944
- d = wa_status(env_id) or {'status': 'disabled'}
1945
- d['ok'] = True
1946
- d['auto_reply'] = bool(env_data['settings'].get('wa_auto_reply'))
1947
- d['is_mobile'] = is_mobile_request()
1948
- return jsonify(d)
1949
-
1950
-
1951
- @app.route('/c/<env_id>/api/wa/start', methods=['POST'])
1952
- def api_wa_start(env_id):
1953
- env_data, err = _client_guard(env_id)
1954
- if err:
1955
- return err
1956
- if is_mobile_request():
1957
- return jsonify({'ok': False, 'mobile': True,
1958
- 'error': '📵 Авторизация WhatsApp возможна только с компьютера. '
1959
- 'Откройте среду на компьютере и отсканируйте QR там.'})
1960
- if not wa_available():
1961
- return jsonify({'ok': False, 'error': 'WhatsApp-коннектор выключен'})
1962
- r = wb_post('/start', {'env': env_id})
1963
- return jsonify(r or {'ok': False, 'error': 'мост не отвечает'})
1964
-
1965
-
1966
- @app.route('/c/<env_id>/api/wa/qr')
1967
- def api_wa_qr(env_id):
1968
- env_data, err = _client_guard(env_id)
1969
- if err:
1970
- return err
1971
- d = wb_get(f'/qr?env={env_id}')
1972
- return jsonify({'ok': True, 'qr': (d or {}).get('qr')})
1973
-
1974
-
1975
- @app.route('/c/<env_id>/api/wa/qr.png')
1976
- def api_wa_qr_png(env_id):
1977
- env_data, err = _client_guard(env_id)
1978
- if err:
1979
- return err
1980
- d = wb_get(f'/qr?env={env_id}')
1981
- qr = (d or {}).get('qr')
1982
- if not qr:
1983
- return Response('', status=204)
1984
- if not _qrcode:
1985
- return Response(qr, mimetype='text/plain')
1986
- img = _qrcode.make(qr)
1987
- buf = io.BytesIO()
1988
- img.save(buf, format='PNG')
1989
- return Response(buf.getvalue(), mimetype='image/png',
1990
- headers={'Cache-Control': 'no-store'})
1991
-
1992
-
1993
- @app.route('/c/<env_id>/api/wa/logout', methods=['POST'])
1994
- def api_wa_logout(env_id):
1995
- env_data, err = _client_guard(env_id)
1996
- if err:
1997
- return err
1998
- wb_post('/logout', {'env': env_id})
1999
- mark_state_dirty()
2000
- return jsonify({'ok': True})
2001
-
2002
-
2003
- @app.route('/c/<env_id>/api/wa/send', methods=['POST'])
2004
- def api_wa_send(env_id):
2005
- env_data, err = _client_guard(env_id)
2006
- if err:
2007
- return err
2008
- p = request.get_json(silent=True) or {}
2009
- r = wb_post('/send', {'env': env_id, 'jid': p.get('to'), 'text': p.get('text', '')})
2010
- return jsonify(r or {'ok': False, 'error': 'мост не отвечает'})
2011
-
2012
-
2013
- @app.route('/c/<env_id>/api/wa/history')
2014
- def api_wa_history(env_id):
2015
- env_data, err = _client_guard(env_id)
2016
- if err:
2017
- return err
2018
- d = wb_get(f'/history?env={env_id}&limit=60')
2019
- hist = (d or {}).get('history')
2020
- if hist is None:
2021
- hist = env_data.get('wa_log', [])[-60:]
2022
- return jsonify({'ok': True, 'history': hist})
2023
-
2024
-
2025
- @app.route('/c/<env_id>/api/wa/settings', methods=['POST'])
2026
- def api_wa_settings(env_id):
2027
- env_data, err = _client_guard(env_id)
2028
- if err:
2029
- return err
2030
- p = request.get_json(silent=True) or {}
2031
- if 'auto_reply' in p:
2032
- env_data['settings']['wa_auto_reply'] = bool(p['auto_reply'])
2033
- if 'groups' in p:
2034
- env_data['settings']['wa_groups'] = bool(p['groups'])
2035
- save_env_data(env_id, env_data)
2036
- return jsonify({'ok': True})
2037
-
2038
-
2039
- # --- события от WA-моста -------------------------------------------------------
2040
- @app.route('/wa/event', methods=['POST'])
2041
- def wa_event():
2042
- if request.headers.get('x-wa-token') != WA_TOKEN:
2043
- return jsonify({'ok': False}), 401
2044
- p = request.get_json(silent=True) or {}
2045
- env_id, etype, rec = p.get('env'), p.get('type'), p.get('payload') or {}
2046
- env_data = get_env_data(env_id)
2047
- if not env_data:
2048
- return jsonify({'ok': True})
2049
- if etype == 'status':
2050
- if rec.get('status') in ('connected', 'logged_out'):
2051
- mark_state_dirty()
2052
- return jsonify({'ok': True})
2053
- if etype == 'message':
2054
- wa_append_log(env_data, rec)
2055
- if rec.get('media'):
2056
- local = os.path.join(MEDIA_DIR, env_id, rec['media']['name'])
2057
- if os.path.exists(local):
2058
- threading.Thread(target=upload_file_to_hf,
2059
- args=(local, f'{MEDIA_DIR}/{env_id}/{rec["media"]["name"]}'),
2060
- daemon=True).start()
2061
- save_env_data(env_id, env_data)
2062
- if (not rec.get('fromMe') and env_data['settings'].get('wa_auto_reply')
2063
- and (not rec.get('group') or env_data['settings'].get('wa_groups'))):
2064
- threading.Thread(target=wa_autoreply_thread, args=(env_id, rec), daemon=True).start()
2065
- return jsonify({'ok': True})
2066
-
2067
-
2068
- # ---------------------------------------------------------------------------
2069
- # Entrypoint
2070
- # ---------------------------------------------------------------------------
2071
- def _shutdown():
2072
- try:
2073
- flush_dirty_envs()
2074
- except Exception:
2075
- pass
2076
- try:
2077
- if state_snapshot_make():
2078
- state_snapshot_upload()
2079
- except Exception:
2080
- pass
2081
- global oc_proc, wa_proc
2082
- for p in (wa_proc, oc_proc):
2083
- if p and p.poll() is None:
2084
- try:
2085
- p.terminate()
2086
- p.wait(timeout=8)
2087
- except Exception:
2088
- try:
2089
- p.kill()
2090
- except Exception:
2091
- pass
2092
-
2093
-
2094
- def main():
2095
- atexit.register(_shutdown)
2096
- threading.Thread(target=_background_sync_worker, daemon=True).start()
2097
- threading.Thread(target=_state_snapshot_worker, daemon=True).start()
2098
-
2099
- if REPO_ID:
2100
- print("[space] restoring state from dataset...", flush=True)
2101
- state_snapshot_restore()
2102
-
2103
- spawn_opencode()
2104
- spawn_wa_bridge()
2105
-
2106
- def forward(signum, _frame):
2107
- _shutdown()
2108
- sys.exit(0)
2109
-
2110
- signal.signal(signal.SIGTERM, forward)
2111
- signal.signal(signal.SIGINT, forward)
2112
-
2113
- host = os.environ.get("OPENCODE_HOST", "0.0.0.0")
2114
- port = int(os.environ.get("OPENCODE_PORT", os.environ.get("PORT", "7860")))
2115
- print(f"[space] flask on {host}:{port}", flush=True)
2116
- app.run(host=host, port=port, threaded=True, debug=False, use_reloader=False)
2117
-
2118
-
2119
- if __name__ == '__main__':
2120
- main()