Delete app.py
Browse files
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/<id></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">← в админку</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="Меню">☰</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">↵</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">✕</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="Медиа">📷</button>
|
| 1149 |
-
<textarea id="text" rows="1" placeholder="Сообщение…" enterkeyhint="send"></textarea>
|
| 1150 |
-
<button class="iconbtn send" id="btnSend" aria-label="Отправить">➤</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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|