#!/usr/bin/env python3 """클로·레이·골디 웹 채팅 (chat.hyowons.net). 텔레그램처럼 세 에이전트와 대화하는 자체 웹. Tailscale 없이 NAS 리버스 프록시로 공개되므로 로그인은 본인 iMessage OTP(iOS 자동입력) → 30일 세션 쿠키. 비밀번호는 없다(관리자님 결정) — 대신 누구나 '코드 받기'를 누를 수 있으므로 발송 횟수를 제한한다(1분 1회, 1시간 5회). 에이전트 호출은 `openclaw agent --agent --session-key web… --json` (게이트웨이 경유, 게이트웨이 자체는 외부에 열지 않는다). 세션 키가 텔레그램과 달라 웹 대화는 웹 전용 맥락이다. 응답은 텔레그램으로 나가지 않는다 (`--deliver` 미지정). CLI: python3 chat_web.py serve [--port 18793] """ from __future__ import annotations import hashlib import hmac import html import json import os import secrets import subprocess import sys import threading import time import urllib.parse from datetime import datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from zoneinfo import ZoneInfo KST = ZoneInfo('Asia/Seoul') ROOT = Path.home() / '.openclaw' STATE = ROOT / 'workspace' / 'state' / 'chat_web' HIST_DIR = STATE / 'history' AUTH_FILE = STATE / 'auth.json' KEYS_FILE = STATE / 'session_keys.json' IMSG_CRED = ROOT / 'credentials' / 'admin_imessage.json' ICON_FILE = Path(__file__).with_name('chat_web_icon.png') OPENCLAW = str(Path.home() / '.local' / 'bin' / 'openclaw') DOMAIN = 'chat.hyowons.net' DEFAULT_PORT = 18793 AGENTS = [('main', '클로', '🦞'), ('stock', '레이', '🪎'), ('budget', '골디', '📒')] AGENT_IDS = {a[0] for a in AGENTS} SESSION_TOOL = ROOT / 'workspace' / 'skills' / 'claude-code-session' / 'scripts' / 'session_tool.py' # Claude Code 세션 바로가기 (session_tool 프로필 이름, 표시명, 이모지). workdir 은 프로필에서 읽는다 CLAUDE_TARGETS = [('openclaw', '코디', '🛠'), ('develop2025', 'Averno', '🌋')] SESSION_TTL = 30 * 86400 OTP_TTL = 300 OTP_MAX_TRIES = 5 FAIL_WINDOW = 900 # 15분 안에 FAIL_LIMIT = 5 # 5번 틀리면 LOCK_SEC = 900 # 15분 잠금 SEND_GAP = 60 # 코드 발송 최소 간격 SEND_HOURLY = 5 # 1시간 발송 상한 (넘으면 잠금 — 폰으로 코드 폭탄 방지) MAX_MSG = 8000 RUN_TIMEOUT = 600 HISTORY_LIMIT = 300 # 웹 메시지에만 덧붙이는 형식 안내 — 에이전트가 선택지를 지정하면 웹이 버튼으로 그린다. # AGENTS.md 에 넣지 않는 이유: 텔레그램엔 버튼이 없어 마커가 날것으로 보인다. 화면 기록엔 저장하지 않는다. WEB_HINT = ('\n\n(웹 채팅 안내: 관리자님이 골라서 답할 질문이면 답변 맨 마지막 줄에 ' '[[버튼: 선택1 | 선택2]] 형식으로 선택지를 적어주세요. 고를 것이 없으면 쓰지 마세요.)') _auth_lock = threading.Lock() _otp: dict[str, dict] = {} # pre 토큰 → {otp, exp, tries} (메모리 — 재시작 시 재로그인) _pending: dict[str, float] = {} # agent → 시작 시각 _run_locks = {a: threading.Lock() for a in AGENT_IDS} _hist_lock = threading.Lock() def now_kst() -> datetime: return datetime.now(KST) def log(msg: str) -> None: print(f'[{now_kst():%Y-%m-%d %H:%M:%S}] {msg}', flush=True) def _read_json(p: Path, default): try: return json.loads(p.read_text(encoding='utf-8')) except Exception: return default def _write_json(p: Path, data) -> None: p.parent.mkdir(parents=True, exist_ok=True) tmp = p.with_suffix(p.suffix + '.tmp') tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding='utf-8') os.replace(tmp, p) # ---------------- 인증 상태 ---------------- def _sha(s: str) -> str: return hashlib.sha256(s.encode()).hexdigest() def _load_auth() -> dict: a = _read_json(AUTH_FILE, {}) a.setdefault('sessions', {}) a.setdefault('fails', []) a.setdefault('locked_until', 0) a.setdefault('sends', []) return a def _take_send_slot() -> str | None: """코드 발송 가능하면 기록하고 None, 아니면 사유. 상한 초과는 잠금으로 이어진다.""" t = time.time() alert = False with _auth_lock: a = _load_auth() a['sends'] = [s for s in a['sends'] if t - s < 3600] if a['sends'] and t - a['sends'][-1] < SEND_GAP: return f'방금 코드를 보냈습니다. {int(SEND_GAP - (t - a["sends"][-1])) + 1}초 뒤 다시 받을 수 있습니다.' if len(a['sends']) >= SEND_HOURLY: a['locked_until'] = t + LOCK_SEC a['sends'] = [] _write_json(AUTH_FILE, a) alert = True else: a['sends'].append(t) _write_json(AUTH_FILE, a) if alert: log('LOCKED — 코드 요청 과다') _alert(f'🔒 chat.hyowons.net 인증 코드 요청이 1시간에 {SEND_HOURLY}회를 넘어 {LOCK_SEC // 60}분 잠갔습니다.') return f'요청이 너무 많아 {LOCK_SEC // 60}분 잠갔습니다.' return None def _locked() -> float: """잠겨 있으면 남은 초, 아니면 0.""" with _auth_lock: left = _load_auth()['locked_until'] - time.time() return max(0.0, left) def _record_fail(ip: str, why: str) -> None: t = time.time() alert = False with _auth_lock: a = _load_auth() a['fails'] = [f for f in a['fails'] if t - f < FAIL_WINDOW] + [t] if len(a['fails']) >= FAIL_LIMIT and a['locked_until'] < t: a['locked_until'] = t + LOCK_SEC a['fails'] = [] alert = True _write_json(AUTH_FILE, a) log(f'login fail ({why}) ip={ip}') if alert: log(f'LOCKED {LOCK_SEC}s') _alert(f'🔒 chat.hyowons.net 로그인 {FAIL_LIMIT}회 실패로 {LOCK_SEC // 60}분 잠갔습니다.\n' f'마지막 시도 IP: {ip} ({why})') def _alert(text: str) -> None: try: sys.path.insert(0, str(ROOT / 'workspace' / 'scripts')) from codex_fallback_monitor import _send_telegram _send_telegram(text) except Exception as e: log(f'alert 실패: {e}') def _new_session(ip: str, ua: str) -> str: tok = secrets.token_urlsafe(32) t = time.time() with _auth_lock: a = _load_auth() a['sessions'] = {k: v for k, v in a['sessions'].items() if v.get('exp', 0) > t} a['sessions'][_sha(tok)] = {'exp': t + SESSION_TTL, 'created': now_kst().isoformat(timespec='seconds'), 'ip': ip, 'ua': ua[:120]} a['fails'] = [] _write_json(AUTH_FILE, a) return tok def _valid_session(tok: str | None) -> bool: if not tok: return False with _auth_lock: s = _load_auth()['sessions'].get(_sha(tok)) return bool(s) and s.get('exp', 0) > time.time() def _drop_session(tok: str | None) -> None: if not tok: return with _auth_lock: a = _load_auth() a['sessions'].pop(_sha(tok), None) _write_json(AUTH_FILE, a) def _send_otp(code: str) -> bool: handle = (_read_json(IMSG_CRED, {}) or {}).get('handle') if not handle: log('admin_imessage.json 없음 — OTP 발송 불가') return False text = f'[{DOMAIN}] 채팅 로그인 인증\n코드: {code}\n\n@{DOMAIN} #{code}' try: # 나→나 iMessage 는 항상 imessage 서비스 강제(SMS 셀프발송은 자동입력이 깨진다) subprocess.Popen(['imsg', 'send', '--to', handle, '--text', text, '--service', 'imessage'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) return True except Exception as e: log(f'imsg 발송 실패: {e}') return False # ---------------- 코디 (Claude Code remote-control 세션) ---------------- # 코디는 이 웹의 대화 상대가 아니다 — 세션 데몬을 띄우고(이미 떠 있으면 재사용) 접속 주소를 돌려줄 뿐이다. # 실제 대화는 Claude 앱/claude.ai 에서 하므로 Anthropic 로그인이 그대로 보호막이 된다. _cody_lock = threading.Lock() def _session_tool(): import importlib.util spec = importlib.util.spec_from_file_location('session_tool', SESSION_TOOL) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod def _cody_url(name: str) -> str | None: """세션 로그의 마지막 화면에서 주소를 찾는다. 연결된 세션이 있으면 그 세션, 없으면 환경 진입 주소.""" import re p = ROOT / 'logs' / f'claude-session-{name}.log' if not p.exists(): return None txt = p.read_bytes()[-200_000:].decode('utf-8', 'ignore') last = txt[txt.rfind('Capacity:'):] if 'Capacity:' in txt else txt # 로그는 화면 다시그리기의 연속 — 마지막 프레임만 본다 m = re.findall(r'https://claude\.ai/code/session_[A-Za-z0-9]+', last) if m: return m[-1] m = re.findall(r'https://claude\.ai/code\?environment=env_[A-Za-z0-9]+', txt) return m[-1] if m else None def _open_cody(profile: str = 'openclaw') -> dict: if profile not in {t[0] for t in CLAUDE_TARGETS}: return {'error': '알 수 없는 대상'} with _cody_lock: st = _session_tool() workdir = Path(st._load_state()['profiles'][profile]['workdir']) running = [x for x in st._list_active_sessions() if x['running'] and Path(x['workdir']) == workdir] if running: name, reused = running[-1]['session'], True else: r = subprocess.run([sys.executable, str(SESSION_TOOL), 'session', 'open', profile], capture_output=True, text=True, timeout=60) import re m = re.search(r'세션 시작됨: (\S+)', r.stdout) if not m: log(f'cody open 실패 rc={r.returncode} {r.stderr[-300:]!r}') return {'error': '세션을 띄우지 못했습니다'} name, reused = m.group(1), False log(f'cody session opened {name}') for _ in range(25): # 새로 띄운 세션은 연결까지 몇 초 걸린다 url = _cody_url(name) if url: return {'session': name, 'url': url, 'reused': reused} time.sleep(1) return {'error': f'세션({name})은 떴지만 접속 주소를 아직 못 찾았습니다', 'session': name} # ---------------- 대화 기록·에이전트 호출 ---------------- def _session_key(agent: str) -> str: return (_read_json(KEYS_FILE, {}) or {}).get(agent, 'web') def _rotate_session(agent: str) -> str: keys = _read_json(KEYS_FILE, {}) or {} key = f'web-{now_kst():%Y%m%d%H%M%S}' keys[agent] = key _write_json(KEYS_FILE, keys) _append(agent, 'system', '새 대화를 시작했습니다') return key def _clear(agent: str) -> None: """/clear — 화면 기록을 archive/ 로 옮기고(삭제 X) 새 세션으로 시작.""" p = HIST_DIR / f'{agent}.jsonl' if p.exists(): arc = HIST_DIR / 'archive' arc.mkdir(parents=True, exist_ok=True) with _hist_lock: os.replace(p, arc / f'{agent}-{now_kst():%Y%m%d%H%M%S}.jsonl') _rotate_session(agent) def _append(agent: str, role: str, text: str) -> dict: item = {'id': f'{time.time_ns()}', 'role': role, 'text': text, 'ts': now_kst().isoformat(timespec='seconds')} with _hist_lock: HIST_DIR.mkdir(parents=True, exist_ok=True) with open(HIST_DIR / f'{agent}.jsonl', 'a', encoding='utf-8') as f: f.write(json.dumps(item, ensure_ascii=False) + '\n') return item def _history(agent: str) -> list[dict]: p = HIST_DIR / f'{agent}.jsonl' if not p.exists(): return [] with _hist_lock: lines = p.read_text(encoding='utf-8').splitlines()[-HISTORY_LIMIT:] out = [] for ln in lines: try: out.append(json.loads(ln)) except Exception: pass return out def _parse_reply(stdout: str) -> tuple[str, str | None]: """(본문, 오류). JSON 앞에 다른 출력이 섞여도 첫 '{' 부터 읽는다.""" i = stdout.find('{') if i < 0: return '', '응답을 해석할 수 없습니다' try: d = json.loads(stdout[i:]) except Exception: return '', '응답을 해석할 수 없습니다' if d.get('status') != 'ok': return '', f"실행 실패: {d.get('summary') or d.get('status')}" parts = [] for p in (d.get('result') or {}).get('payloads') or []: if p.get('text'): parts.append(p['text']) if p.get('mediaUrl'): parts.append(p['mediaUrl']) return '\n\n'.join(parts) or '(빈 응답)', None def _run_agent(agent: str, text: str) -> None: with _run_locks[agent]: _pending[agent] = time.time() msg_file = STATE / 'tmp' / f'{agent}-{time.time_ns()}.txt' try: msg_file.parent.mkdir(parents=True, exist_ok=True) msg_file.write_text(text if text.startswith('/') else text + WEB_HINT, encoding='utf-8') # 슬래시 명령은 본체가 처리하므로 그대로 r = subprocess.run( [OPENCLAW, 'agent', '--agent', agent, '--session-key', _session_key(agent), '--message-file', str(msg_file), '--json', '--timeout', str(RUN_TIMEOUT)], capture_output=True, text=True, timeout=RUN_TIMEOUT + 60, cwd=str(ROOT)) body, err = _parse_reply(r.stdout) if err: log(f'{agent} 실패 rc={r.returncode} err={r.stderr[-500:]!r}') _append(agent, 'error', err) else: _append(agent, 'agent', body) except subprocess.TimeoutExpired: _append(agent, 'error', f'{RUN_TIMEOUT // 60}분 안에 응답이 없어 중단했습니다') except Exception as e: log(f'{agent} 예외: {e}') _append(agent, 'error', f'호출 오류: {e}') finally: msg_file.unlink(missing_ok=True) _pending.pop(agent, None) # ---------------- HTML ---------------- CSS = """ :root{--bg:#f4f5f7;--panel:#fff;--fg:#1c1e21;--muted:#7a7f87;--line:#e3e5e8;--me:#d9f3c9;--them:#fff; --accent:#2a7ae2;--err:#c62828;--chip:#eef1f5} @media (prefers-color-scheme:dark){:root{--bg:#0f1115;--panel:#181b21;--fg:#e6e8eb;--muted:#8a9099; --line:#2a2e36;--me:#2b4a2a;--them:#22262e;--accent:#5b9cf0;--err:#ef6b6b;--chip:#232831}} *{box-sizing:border-box}html,body{margin:0;height:100%} body{background:var(--bg);color:var(--fg);font:15px/1.5 -apple-system,BlinkMacSystemFont,"Apple SD Gothic Neo",sans-serif} button,input,textarea{font:inherit;color:inherit} """ LOGIN_CSS = CSS + """ .box{max-width:340px;margin:12vh auto 0;padding:24px 20px;background:var(--panel);border:1px solid var(--line);border-radius:14px} h1{font-size:18px;margin:0 0 4px}p{color:var(--muted);font-size:13px;margin:0 0 16px} input{width:100%;font-size:17px;padding:11px 12px;border:1px solid var(--line);border-radius:10px;background:var(--bg);margin-bottom:12px} button{width:100%;padding:11px;border:0;border-radius:10px;background:var(--accent);color:#fff;font-weight:600} .err{color:var(--err);font-size:13px;margin:-4px 0 12px} """ def _page(title: str, body: str, css: str = LOGIN_CSS, extra_head: str = '') -> bytes: return (f'' f'' f'{html.escape(title)}' # 홈 화면 아이콘 — 없으면 iOS 가 제목 첫 글자('채')로 아이콘을 만든다 f'' f'' f'' f'{extra_head}' f'{body}').encode() def _login_page(err: str = '') -> bytes: e = f'
{html.escape(err)}
' if err else '' return _page('채팅 로그인', f'''

💬 채팅 로그인

클로 · 레이 · 골디 — 관리자님 iMessage로 인증 코드를 보냅니다

{e}
''') def _otp_page(err: str = '') -> bytes: e = f'
{html.escape(err)}
' if err else '' return _page('인증 코드', f'''

📱 인증 코드

iMessage로 보낸 6자리 코드를 입력하세요 (5분 유효)

{e}
''') APP_CSS = CSS + """ /* 페이지 자체는 움직이지 않고 대화 영역(main)만 스크롤 — iOS 바운스·키보드가 상단/하단 프레임을 끌고 가지 않게 */ html,body{overflow:hidden;overscroll-behavior:none} body{position:fixed;top:0;left:0;right:0;display:flex;flex-direction:column;height:100dvh} header{display:flex;align-items:center;gap:6px;padding:8px 10px;padding-top:max(8px,env(safe-area-inset-top));background:var(--panel);border-bottom:1px solid var(--line)} .title{flex:1;min-width:0;font-weight:600;font-size:16px;padding-left:4px} .agent-pick{position:relative;flex:none} .agent-btn{position:relative;width:40px;height:40px;border-radius:50%;border:1px solid var(--line);background:var(--chip);font-size:20px;line-height:1;padding:0;cursor:pointer} .dot{width:8px;height:8px;border-radius:50%;background:var(--err);display:none} .agent-btn .dot{position:absolute;top:0;right:0}.agent-btn.unread .dot{display:block} .agent-list{position:absolute;left:0;bottom:calc(100% + 8px);min-width:170px;background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:0 6px 20px rgba(0,0,0,.18);padding:4px 0;display:none;z-index:6} .agent-list.open{display:block} .agent-item{display:flex;align-items:center;gap:8px;width:100%;border:0;background:none;padding:10px 14px;text-align:left;cursor:pointer} .agent-item.on{color:var(--accent);font-weight:600}.agent-item .st{margin-left:auto;font-size:12px;color:var(--muted)} .agent-item.unread .dot{display:inline-block} .agent-item.cody{border-top:1px solid var(--line)} .cody-box{position:fixed;inset:0;background:rgba(0,0,0,.35);display:none;align-items:center;justify-content:center;z-index:9} .cody-box.open{display:flex} .cody-card{background:var(--panel);border-radius:14px;padding:18px;width:min(300px,86vw);text-align:center} .cody-card .msg2{margin:8px 0 14px;color:var(--muted);font-size:13px} .cody-card a{display:block;background:var(--accent);color:#fff;text-decoration:none;border-radius:10px;padding:10px;font-weight:600} .cody-card button{margin-top:10px;border:0;background:none;color:var(--muted);cursor:pointer} .menu-btn{border:0;background:none;font-size:20px;padding:4px 8px;cursor:pointer} .menu{position:absolute;right:10px;top:48px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 6px 20px rgba(0,0,0,.15);display:none;z-index:5} .menu.open{display:block}.menu button{display:block;width:100%;text-align:left;border:0;background:none;padding:10px 16px;cursor:pointer} main{flex:1;min-height:0;overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;padding:12px 10px} .msg{max-width:min(82%,640px);margin:6px 0;padding:8px 11px;border-radius:14px;word-wrap:break-word;overflow-wrap:anywhere} .msg.user{margin-left:auto;background:var(--me);border-bottom-right-radius:4px} .msg.agent{background:var(--them);border:1px solid var(--line);border-bottom-left-radius:4px} .msg.error{background:none;border:1px dashed var(--err);color:var(--err);font-size:13px} .msg .t{display:block;font-size:11px;color:var(--muted);margin-top:2px;text-align:right} .sys{text-align:center;color:var(--muted);font-size:12px;margin:14px 0} .msg pre{background:var(--chip);padding:8px;border-radius:8px;overflow-x:auto;font-size:13px;margin:6px 0} .msg code{background:var(--chip);padding:1px 4px;border-radius:4px;font-size:13px}.msg pre code{background:none;padding:0} .msg a{color:var(--accent)} .typing{color:var(--muted);font-size:13px;margin:8px 4px;display:none}.typing.on{display:block} footer{display:flex;gap:8px;align-items:flex-end;padding:8px 10px;padding-bottom:max(8px,env(safe-area-inset-bottom));background:var(--panel);border-top:1px solid var(--line)} textarea{flex:1;min-width:0;resize:none;max-height:40vh;font-size:16px;padding:9px 12px;border:1px solid var(--line);border-radius:20px;background:var(--bg)} #send{border:0;background:var(--accent);color:#fff;border-radius:50%;width:40px;height:40px;flex:none;font-size:18px;cursor:pointer} #send:disabled{opacity:.4} .empty{color:var(--muted);text-align:center;margin-top:30vh;font-size:14px} .choices{display:flex;flex-wrap:wrap;gap:6px;margin:2px 0 8px} .choice{border:1px solid var(--accent);background:var(--panel);color:var(--accent);border-radius:16px;padding:6px 14px;font-size:14px;cursor:pointer} .choice:disabled{opacity:.4} /* 모바일은 약 80% 크기 (관리자님 요청). 입력칸이 16px 미만이면 iOS 가 포커스 때 확대하므로 viewport maximum-scale=1 로 막는다 */ @media (max-width:600px){ body{font-size:12px} header{padding:6px 8px;padding-top:max(6px,env(safe-area-inset-top))}.title{font-size:13px}.menu-btn{font-size:16px}.menu{top:38px} main{padding:10px 8px;font-size:13.5px}.msg{padding:6px 9px;border-radius:11px;margin:5px 0}.msg .t{font-size:9px}.sys{font-size:10px} .msg pre,.msg code{font-size:12px}.msg.error,.typing{font-size:11px}.empty{font-size:12px} footer{gap:6px;padding:6px 8px;padding-bottom:max(6px,env(safe-area-inset-bottom))} textarea{font-size:13px;padding:7px 10px;border-radius:16px} #send,.agent-btn{width:32px;height:32px}#send{font-size:14px}.agent-btn{font-size:16px} .agent-item{padding:8px 12px}.agent-item .st{font-size:10px} .choice{font-size:12px;padding:5px 11px} } """ def _app_page() -> bytes: agents = json.dumps([{'id': a, 'name': n, 'emoji': e} for a, n, e in AGENTS], ensure_ascii=False) targets = json.dumps(CLAUDE_TARGETS, ensure_ascii=False) body = f'''
''' return _page('채팅', body, APP_CSS) APP_JS = r""" (function(){ // 키보드가 올라오면 iOS 는 보이는 영역(visualViewport)만 줄이고 페이지를 밀어 올린다 → 몸통을 보이는 영역에 맞춘다 const vv=window.visualViewport; if(vv){const fit=()=>{const m=document.getElementById('log'),stick=m&&m.scrollHeight-m.clientHeight-m.scrollTop<40; document.body.style.height=vv.height+'px';document.body.style.top=vv.offsetTop+'px'; if(stick)m.scrollTop=m.scrollHeight}; // 맨 아래를 보던 중이면 키보드가 올라와도 마지막 메시지를 가리지 않게 vv.addEventListener('resize',fit);vv.addEventListener('scroll',fit);fit()} const CT=window.CT,A=window.AGENTS, $=s=>document.querySelector(s); const log=$('#log'), input=$('#input'), send=$('#send'), typing=$('#typing'); let cur=localStorage.getItem('cw_agent')||'main', status={}, lastSeen={}, timer=null; try{lastSeen=JSON.parse(localStorage.getItem('cw_seen')||'{}')}catch(e){} if(!A.some(a=>a.id===cur))cur='main'; function api(path,opt){opt=opt||{};opt.headers=Object.assign({'X-CW':'1'},opt.headers||{}); return fetch(path,opt).then(r=>{if(r.status===401){location.href='/';throw new Error('auth')}return r.json()})} function esc(s){return s.replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]))} function md(s){ const blocks=[];s=esc(s).replace(/\n?```[\w-]*\n?([\s\S]*?)\n?```\n?/g,(m,c)=>{blocks.push(c);return '\u0000'+(blocks.length-1)+'\u0000'}); s=s.replace(/`([^`\n]+)`/g,'$1').replace(/\*\*([^*\n]+)\*\*/g,'$1') .replace(/(https?:\/\/[^\s<]+)/g,'$1').replace(/\n/g,'
'); return s.replace(/\u0000(\d+)\u0000/g,(m,i)=>'
'+blocks[i]+'
'); } // 답변 속 "수정해줘"라고 / '네'라고 같은 '따옴표 + (이)라고' 표현을 선택지 버튼으로 뽑는다 const BTN=/\[\[\s*버튼\s*:\s*([^\]\n]+)\]\]/; function strip(t){return t.replace(new RegExp(BTN.source,'g'),'').replace(/\n{3,}/g,'\n\n').trim()} // 1순위: 에이전트가 지정한 [[버튼: A | B]] / 없으면 답변 속 "수정해줘"라고 같은 표현에서 추출 function choices(t){const g=BTN.exec(t);if(g)return [...new Set(g[1].split('|').map(x=>x.trim()).filter(x=>x&&x.length<=30))].slice(0,6); const out=[],re=/\*{0,2}[“"‘'「]([^”"’'」\n]{1,20})[”"’'」]\*{0,2}\s*(?:이)?라고/g;let m; while((m=re.exec(t))){const c=m[1].trim();if(c&&!out.includes(c))out.push(c)}return out.slice(0,6)} function hm(ts){return ts?ts.slice(11,16):''} function renderTabs(){ const ca=A.find(x=>x.id===cur);$('#title').textContent=ca.emoji+' '+ca.name; $('#agentEmoji').textContent=ca.emoji;let anyUn=false; $('#agentList').innerHTML=A.map(a=>{const st=status[a.id]||{};const un=a.id!==cur&&st.last&&st.last!==lastSeen[a.id];if(un)anyUn=true; return ''}).join('') +CT.map((t,i)=>'').join(''); $('#agentBtn').classList.toggle('unread',anyUn); } function renderLog(items){ const a=A.find(x=>x.id===cur); if(!items.length){log.innerHTML='
'+a.emoji+' '+a.name+'에게 말을 걸어보세요
';return} log.innerHTML=items.map(m=>m.role==='system'?'
— '+esc(m.text)+' · '+m.ts.slice(5,16).replace('T',' ')+' —
' :'
'+(m.role==='user'?esc(m.text).replace(/\n/g,'
'):md(strip(m.text)))+''+hm(m.ts)+'
').join(''); const last=items[items.length-1], ch=last.role==='agent'?choices(last.text):[]; if(ch.length)log.insertAdjacentHTML('beforeend','
'+ch.map(c=>'').join('')+'
'); log.scrollTop=log.scrollHeight; } function load(){return api('/api/history?agent='+cur).then(d=>{renderLog(d.items); if(d.items.length){lastSeen[cur]=d.items[d.items.length-1].id;localStorage.setItem('cw_seen',JSON.stringify(lastSeen))} setTyping(d.pending);renderTabs()})} function setTyping(p){const a=A.find(x=>x.id===cur);typing.textContent=a.emoji+' '+a.name+' 입력 중…';typing.classList.toggle('on',!!p);send.disabled=!!p;log.querySelectorAll('.choice').forEach(b=>b.disabled=!!p)} function poll(){ api('/api/status').then(d=>{const prev=status;status=d; if(prev[cur]&&prev[cur].last!==d[cur].last)load();else{setTyping(d[cur].pending);renderTabs()} const any=Object.values(d).some(s=>s.pending);clearTimeout(timer);timer=setTimeout(poll,any?2000:20000)}).catch(()=>{clearTimeout(timer);timer=setTimeout(poll,5000)}); } function doSend(choice){ const fromInput=typeof choice!=='string',t=fromInput?input.value.trim():choice;if(!t||send.disabled)return;send.disabled=true; log.querySelectorAll('.choice').forEach(b=>b.disabled=true); api('/api/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent:cur,text:t})}) .then(d=>{if(d.error){alert(d.error);send.disabled=false;return}if(fromInput){input.value='';autosize()}load().then(()=>{clearTimeout(timer);timer=setTimeout(poll,1500)})}) .catch(()=>{send.disabled=false}); } function autosize(){input.style.height='auto';input.style.height=Math.min(input.scrollHeight,window.innerHeight*0.4)+'px'} $('#agentBtn').addEventListener('click',e=>{e.stopPropagation();$('#menu').classList.remove('open');$('#agentList').classList.toggle('open')}); $('#agentList').addEventListener('click',e=>{const b=e.target.closest('.agent-item');if(!b)return;e.stopPropagation();$('#agentList').classList.remove('open'); if(b.dataset.cody){openCody(b.dataset.cody,b.dataset.label);return} if(b.dataset.id!==cur){cur=b.dataset.id;localStorage.setItem('cw_agent',cur);load()}}); send.addEventListener('click',()=>doSend()); log.addEventListener('click',e=>{const b=e.target.closest('.choice');if(b&&!b.disabled)doSend(b.dataset.choice)}); input.addEventListener('input',autosize); input.addEventListener('keydown',e=>{if(e.key==='Enter'&&(e.metaKey||e.ctrlKey)){e.preventDefault();doSend()}}); $('#menuBtn').addEventListener('click',e=>{e.stopPropagation();$('#menu').classList.toggle('open')}); document.addEventListener('click',()=>{$('#menu').classList.remove('open');$('#agentList').classList.remove('open')}); $('#statusBtn').addEventListener('click',()=>doSend('/status')); // 코디: 세션을 띄우고(떠 있으면 재사용) 주소를 받아 버튼으로 준다 — iOS 는 사용자가 직접 누른 링크여야 앱으로 넘긴다 function openCody(profile,label){const box=$('#codyBox'),msg=$('#codyMsg'),a=$('#codyLink');$('#codyTitle').textContent=label; msg.textContent='세션 준비 중… (처음 띄우면 10초쯤 걸립니다)';a.style.display='none';box.classList.add('open'); api('/api/cody',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({profile})}).then(d=>{ if(d.error){msg.textContent=d.error;return} msg.textContent=(d.reused?'떠 있는 세션':'새 세션')+' '+d.session+(d.url.includes('/session_')?' — 진행 중인 대화로 엽니다':' — 이 환경에서 새 대화를 시작합니다'); a.href=d.url;a.style.display='block'}).catch(()=>{msg.textContent='요청 실패'})} $('#codyClose').addEventListener('click',()=>$('#codyBox').classList.remove('open')); $('#codyLink').addEventListener('click',()=>setTimeout(()=>$('#codyBox').classList.remove('open'),300)); $('#resetBtn').addEventListener('click',()=>{const a=A.find(x=>x.id===cur); if(!confirm(a.name+'와의 대화 맥락을 새로 시작할까요? (기록은 화면에 남습니다)'))return; api('/api/reset',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent:cur})}).then(d=>{if(d.error)alert(d.error);load()})}); document.addEventListener('visibilitychange',()=>{if(!document.hidden){load();poll()}}); load();poll(); })(); """ # ---------------- HTTP ---------------- class Handler(BaseHTTPRequestHandler): server_version = 'chat-web' def log_message(self, *a): pass # --- 공용 --- def _ip(self) -> str: xf = self.headers.get('X-Forwarded-For', '') return xf.split(',')[0].strip() if xf else self.client_address[0] def _cookie(self, name: str) -> str | None: for part in (self.headers.get('Cookie') or '').split(';'): k, _, v = part.strip().partition('=') if k == name: return v return None def _send(self, code: int, body: bytes, ctype: str = 'text/html; charset=utf-8', cookies: list[str] | None = None, location: str | None = None) -> None: self.send_response(code) self.send_header('Content-Type', ctype) self.send_header('Content-Length', str(len(body))) self.send_header('Cache-Control', 'no-store') self.send_header('X-Frame-Options', 'DENY') self.send_header('X-Content-Type-Options', 'nosniff') self.send_header('Referrer-Policy', 'no-referrer') self.send_header('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'unsafe-inline'; " "img-src 'self' data:; frame-ancestors 'none'; form-action 'self'") for c in cookies or []: self.send_header('Set-Cookie', c) if location: self.send_header('Location', location) self.end_headers() if self.command != 'HEAD': self.wfile.write(body) def _json(self, data, code: int = 200) -> None: self._send(code, json.dumps(data, ensure_ascii=False).encode(), 'application/json; charset=utf-8') def _redirect(self, to: str, cookies: list[str] | None = None) -> None: self._send(303, b'', cookies=cookies, location=to) @staticmethod def _ck(name: str, val: str, max_age: int, samesite: str = 'Lax') -> str: return f'{name}={val}; Path=/; Max-Age={max_age}; HttpOnly; Secure; SameSite={samesite}' def _authed(self) -> bool: return _valid_session(self._cookie('cw_sess')) def _body(self) -> bytes: n = int(self.headers.get('Content-Length') or 0) return self.rfile.read(min(n, 64 * 1024)) if n > 0 else b'' def _form(self) -> dict: return {k: v[0] for k, v in urllib.parse.parse_qs(self._body().decode('utf-8', 'replace')).items()} # --- GET --- def do_GET(self): path, _, qs = self.path.partition('?') q = {k: v[0] for k, v in urllib.parse.parse_qs(qs).items()} if path == '/': if self._authed(): return self._send(200, _app_page()) if self._cookie('cw_pre') in _otp: return self._send(200, _otp_page()) return self._send(200, _login_page()) if path in ('/apple-touch-icon.png', '/apple-touch-icon-precomposed.png'): # 인증 밖 — iOS 가 홈 화면 추가 시 쿠키 없이 가져갈 수 있다 return self._send(200, ICON_FILE.read_bytes(), 'image/png') if not self._authed(): return self._json({'error': 'auth'}, 401) if path.startswith('/api/') else self._redirect('/') if path == '/app.js': return self._send(200, APP_JS.encode(), 'application/javascript; charset=utf-8') if path == '/api/history': ag = q.get('agent', '') if ag not in AGENT_IDS: return self._json({'error': 'agent'}, 400) return self._json({'items': _history(ag), 'pending': ag in _pending}) if path == '/api/status': out = {} for ag in AGENT_IDS: h = _history(ag) out[ag] = {'pending': ag in _pending, 'last': h[-1]['id'] if h else None} return self._json(out) self._send(404, b'not found', 'text/plain') do_HEAD = do_GET # --- POST --- def do_POST(self): path = self.path.partition('?')[0] ip = self._ip() if path == '/login/request': has_pre = self._cookie('cw_pre') in _otp page = _otp_page if has_pre else _login_page left = _locked() if left: return self._send(429, _login_page(f'잠겨 있습니다. {int(left // 60) + 1}분 뒤 다시 시도하세요.')) why = _take_send_slot() if why: log(f'code request refused ip={ip}') return self._send(429, page(why)) code = f'{secrets.randbelow(10**6):06d}' pre = secrets.token_urlsafe(24) t = time.time() for k in [k for k, v in _otp.items() if v['exp'] < t]: _otp.pop(k, None) _otp.pop(self._cookie('cw_pre') or '', None) # 다시 받기 — 이전 코드는 무효 _otp[pre] = {'otp': code, 'exp': t + OTP_TTL, 'tries': 0} if not _send_otp(code): _otp.pop(pre, None) return self._send(503, _login_page('인증 코드를 보내지 못했습니다.')) log(f'otp sent ip={ip}') return self._redirect('/', [self._ck('cw_pre', pre, OTP_TTL, 'Strict')]) if path == '/login/otp': f = self._form() pre = self._cookie('cw_pre') ent = _otp.get(pre or '') clear = [self._ck('cw_pre', '', 0, 'Strict')] if _locked(): _otp.pop(pre or '', None) return self._redirect('/', clear) if not ent or ent['exp'] < time.time(): _otp.pop(pre or '', None) return self._send(401, _login_page('코드가 만료됐습니다. 다시 로그인하세요.'), cookies=clear) if not hmac.compare_digest(f.get('otp', '').strip(), ent['otp']): ent['tries'] += 1 _record_fail(ip, 'otp') if ent['tries'] >= OTP_MAX_TRIES: _otp.pop(pre, None) return self._send(401, _login_page('코드를 여러 번 틀렸습니다. 다시 로그인하세요.'), cookies=clear) return self._send(401, _otp_page('코드가 맞지 않습니다.')) _otp.pop(pre, None) tok = _new_session(ip, self.headers.get('User-Agent', '')) log(f'login ok ip={ip}') return self._redirect('/', clear + [self._ck('cw_sess', tok, SESSION_TTL)]) if path == '/logout': _drop_session(self._cookie('cw_sess')) return self._redirect('/', [self._ck('cw_sess', '', 0)]) # 이하 API — 세션 + 커스텀 헤더(교차 출처 요청은 이 헤더를 붙이려면 CORS 사전요청을 거쳐야 해 차단된다) if not self._authed() or self.headers.get('X-CW') != '1': return self._json({'error': 'auth'}, 401) try: data = json.loads(self._body() or b'{}') except Exception: return self._json({'error': 'bad json'}, 400) if path == '/api/cody': return self._json(_open_cody(data.get('profile') or 'openclaw')) ag = data.get('agent') if ag not in AGENT_IDS: return self._json({'error': 'agent'}, 400) if path == '/api/send': text = (data.get('text') or '').strip() if not text: return self._json({'error': '빈 메시지'}, 400) if len(text) > MAX_MSG: return self._json({'error': f'{MAX_MSG}자 이하로 보내주세요'}, 400) if ag in _pending: return self._json({'error': '이전 답을 기다리는 중입니다'}, 409) if text.lower() == '/clear': # 웹 전용 명령 — 에이전트로 보내지 않는다 _clear(ag) return self._json({'ok': True, 'cleared': True}) item = _append(ag, 'user', text) _pending[ag] = time.time() # 스레드 시작 전 표시 — 연속 클릭 이중 발송 방지 threading.Thread(target=_run_agent, args=(ag, text), daemon=True).start() return self._json({'ok': True, 'item': item}) if path == '/api/reset': if ag in _pending: return self._json({'error': '답을 기다리는 중에는 새로 시작할 수 없습니다'}, 409) return self._json({'ok': True, 'session': _rotate_session(ag)}) self._json({'error': 'not found'}, 404) def cmd_serve(port: int) -> None: STATE.mkdir(parents=True, exist_ok=True) srv = ThreadingHTTPServer(('', port), Handler) log(f'chat_web serve :{port}') srv.serve_forever() if __name__ == '__main__': args = sys.argv[1:] if args[:1] == ['serve']: port = int(args[args.index('--port') + 1]) if '--port' in args else DEFAULT_PORT cmd_serve(port) else: print(__doc__)