웹 채팅(chat.hyowons.net) 추가 — 클로·레이·골디와 대화
- workspace/scripts/chat_web.py: 표준 라이브러리 단일 서버(포트 18793, launchd ai.openclaw.chat-web) - 로그인: iMessage OTP 단독 + 30일 세션, 코드 발송 1분 1회·1시간 5회 제한, 실패 시 잠금+텔레그램 알림 - 에이전트 호출은 openclaw agent --session-key web… (텔레그램과 별도 맥락, 답은 웹에만) - /clear(웹 전용), 선택지 버튼([[버튼: A | B]] 지정 + 문장 추출 폴백), 홈 화면 아이콘 - CLAUDE.md·TASKS.md(헬스체크 daemon 등록)·클로 MEMORY.md 문서화 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **OpenClaw Gateway** | `openclaw gateway --port 18789` | launchd `ai.openclaw.gateway` (상시) |
|
| **OpenClaw Gateway** | `openclaw gateway --port 18789` | launchd `ai.openclaw.gateway` (상시) |
|
||||||
| **비하이브 웹뷰** | `python3 ~/.openclaw/agents/stock/workspace/scripts/behive_web.py serve` | launchd `ai.openclaw.stock.behive-web` (상시) |
|
| **비하이브 웹뷰** | `python3 ~/.openclaw/agents/stock/workspace/scripts/behive_web.py serve` | launchd `ai.openclaw.stock.behive-web` (상시) |
|
||||||
|
| **웹 채팅 (클로·레이·골디)** | `python3 ~/.openclaw/workspace/scripts/chat_web.py serve` | launchd `ai.openclaw.chat-web` (상시, 포트 18793). `chat.hyowons.net` → NAS 리버스 프록시. 로그인 = iMessage OTP 단독(비밀번호 없음, 코드 발송 1분 1회·1시간 5회 제한) |
|
||||||
| **후잉 동기화** | `python3 ~/.openclaw/agents/budget/workspace/skills/whooing-sync/scripts/whooing_sync.py` | launchd `ai.openclaw.budget.whooing-sync` (매 15분). 매 사이클 끝에 **가희 잔액 리마인더·자동분개** 모듈(`gahee_reminder.run`)도 함께 호출 — 매월 25일 10:00 KST 이후 첫 사이클에서 가희님께 iMessage 1회 발신, 이후 답신 폴링 → 텍스트면 후잉 가희주머니 차액 자동 분개, 이미지면 텔레그램 알림만 |
|
| **후잉 동기화** | `python3 ~/.openclaw/agents/budget/workspace/skills/whooing-sync/scripts/whooing_sync.py` | launchd `ai.openclaw.budget.whooing-sync` (매 15분). 매 사이클 끝에 **가희 잔액 리마인더·자동분개** 모듈(`gahee_reminder.run`)도 함께 호출 — 매월 25일 10:00 KST 이후 첫 사이클에서 가희님께 iMessage 1회 발신, 이후 답신 폴링 → 텍스트면 후잉 가희주머니 차액 자동 분개, 이미지면 텔레그램 알림만 |
|
||||||
| **주식 포트폴리오 스냅샷** | `python3 ~/.openclaw/agents/stock/workspace/scripts/stock_portfolio_report.py send --no-mail` | launchd `ai.openclaw.stock.briefing` (평일 20:10) + 폴백 `-fallback-2030`(20:30, 스냅샷 없을 때만) · `-fallback-2100`(21:00, **무조건 fresh 재조회**). **메일 발송 중지**(2026-08-12 관리자님 결정 — 메일 통수 축소). `--no-mail`은 휴장일 self-skip·실패 텔레그램 알림은 유지하고 메일만 건너뜀. 목적은 자산웹 '당일 평가손익' 기준값이 되는 스냅샷 확보. 리포트 본문을 눈으로 보려면 `run`(메일 없이 평문+`/tmp/stock_report_preview.html`) |
|
| **주식 포트폴리오 스냅샷** | `python3 ~/.openclaw/agents/stock/workspace/scripts/stock_portfolio_report.py send --no-mail` | launchd `ai.openclaw.stock.briefing` (평일 20:10) + 폴백 `-fallback-2030`(20:30, 스냅샷 없을 때만) · `-fallback-2100`(21:00, **무조건 fresh 재조회**). **메일 발송 중지**(2026-08-12 관리자님 결정 — 메일 통수 축소). `--no-mail`은 휴장일 self-skip·실패 텔레그램 알림은 유지하고 메일만 건너뜀. 목적은 자산웹 '당일 평가손익' 기준값이 되는 스냅샷 확보. 리포트 본문을 눈으로 보려면 `run`(메일 없이 평문+`/tmp/stock_report_preview.html`) |
|
||||||
| **증권잔액 → 골디 inbox** | `python3 ~/.openclaw/agents/stock/workspace/scripts/send_balance_to_budget.py` | launchd `ai.openclaw.stock.send-balance` (매월 1일 04:30) |
|
| **증권잔액 → 골디 inbox** | `python3 ~/.openclaw/agents/stock/workspace/scripts/send_balance_to_budget.py` | launchd `ai.openclaw.stock.send-balance` (매월 1일 04:30) |
|
||||||
@@ -77,6 +78,7 @@ LLM이 자막 요약·뉴스 평문 생성·결산 보고서 등 본문을 만
|
|||||||
| OpenClaw Gateway | `daemon:ai.openclaw.gateway` | - |
|
| OpenClaw Gateway | `daemon:ai.openclaw.gateway` | - |
|
||||||
| 비하이브 웹뷰 | `daemon:ai.openclaw.stock.behive-web` | - |
|
| 비하이브 웹뷰 | `daemon:ai.openclaw.stock.behive-web` | - |
|
||||||
| 시뮬 대시보드 | `daemon:ai.openclaw.stock.sim-web` | - |
|
| 시뮬 대시보드 | `daemon:ai.openclaw.stock.sim-web` | - |
|
||||||
|
| 웹 채팅 | `daemon:ai.openclaw.chat-web` | - |
|
||||||
| 후잉 동기화 | `logs/whooing-sync.log` | 2h |
|
| 후잉 동기화 | `logs/whooing-sync.log` | 2h |
|
||||||
| codex 폴백 감시 | `logs/codex-fallback-monitor.log` | 2h |
|
| codex 폴백 감시 | `logs/codex-fallback-monitor.log` | 2h |
|
||||||
| 급등락 알림 | `logs/stock-surge-monitor.log` | 100h |
|
| 급등락 알림 | `logs/stock-surge-monitor.log` | 100h |
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
| 이름 | 일정 (Asia/Seoul) | plist / 스크립트 |
|
| 이름 | 일정 (Asia/Seoul) | plist / 스크립트 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Gmail 라벨 분류/정리 | 매일 01:00 | `ai.openclaw.gmail-label-classify` → `scripts/gmail_label_classify.py` (`[오전/오후 브리핑]` → `브리핑`, `[비하이브 종목분석]` → `종목분석`, `[주식 리포트]` → `주식브리핑`, 24시간 지난 테스트메일 휴지통). `gog` CLI 직접 호출. 2026-06-26 cron→launchd 이관(본문 생성 없는 단순 스크립트). 보고 없음 — 로그 `logs/gmail-label-classify.{log,err.log}` + `state/gmail_label_classify.log` |
|
| Gmail 라벨 분류/정리 | 매일 01:00 | `ai.openclaw.gmail-label-classify` → `scripts/gmail_label_classify.py` (`[오전/오후 브리핑]` → `브리핑`, `[비하이브 종목분석]` → `종목분석`, `[주식 리포트]` → `주식브리핑`, 24시간 지난 테스트메일 휴지통). `gog` CLI 직접 호출. 2026-06-26 cron→launchd 이관(본문 생성 없는 단순 스크립트). 보고 없음 — 로그 `logs/gmail-label-classify.{log,err.log}` + `state/gmail_label_classify.log` |
|
||||||
|
| 웹 채팅 서버 | 상시 | `ai.openclaw.chat-web` → `scripts/chat_web.py serve` (2026-10-07 도입). `chat.hyowons.net` 에서 관리자님이 클로·레이·골디와 대화하는 자체 웹. 내게 오는 웹 메시지는 세션 키 `web…`(텔레그램과 별도 맥락)이고 답은 웹에만 남는다(텔레그램 발송 X). 기록 `state/chat_web/history/`, 로그 `logs/chat-web.{log,err.log}` |
|
||||||
|
|
||||||
### 원칙
|
### 원칙
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,673 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""클로·레이·골디 웹 채팅 (chat.hyowons.net).
|
||||||
|
|
||||||
|
텔레그램처럼 세 에이전트와 대화하는 자체 웹. Tailscale 없이 NAS 리버스 프록시로 공개되므로
|
||||||
|
로그인은 본인 iMessage OTP(iOS 자동입력) → 30일 세션 쿠키. 비밀번호는 없다(관리자님 결정) — 대신 누구나
|
||||||
|
'코드 받기'를 누를 수 있으므로 발송 횟수를 제한한다(1분 1회, 1시간 5회).
|
||||||
|
|
||||||
|
에이전트 호출은 `openclaw agent --agent <id> --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_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
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 대화 기록·에이전트 호출 ----------------
|
||||||
|
|
||||||
|
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'<!doctype html><html lang="ko"><head><meta charset="utf-8">'
|
||||||
|
f'<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">'
|
||||||
|
f'<title>{html.escape(title)}</title>'
|
||||||
|
# 홈 화면 아이콘 — 없으면 iOS 가 제목 첫 글자('채')로 아이콘을 만든다
|
||||||
|
f'<link rel="apple-touch-icon" href="/apple-touch-icon.png">'
|
||||||
|
f'<meta name="apple-mobile-web-app-capable" content="yes">'
|
||||||
|
f'<meta name="apple-mobile-web-app-title" content="채팅">'
|
||||||
|
f'<style>{css}</style>{extra_head}</head>'
|
||||||
|
f'<body>{body}</body></html>').encode()
|
||||||
|
|
||||||
|
|
||||||
|
def _login_page(err: str = '') -> bytes:
|
||||||
|
e = f'<div class="err">{html.escape(err)}</div>' if err else ''
|
||||||
|
return _page('채팅 로그인', f'''<form class="box" method="post" action="/login/request">
|
||||||
|
<h1>💬 채팅 로그인</h1><p>클로 · 레이 · 골디 — 관리자님 iMessage로 인증 코드를 보냅니다</p>{e}
|
||||||
|
<button>인증 코드 받기</button></form>''')
|
||||||
|
|
||||||
|
|
||||||
|
def _otp_page(err: str = '') -> bytes:
|
||||||
|
e = f'<div class="err">{html.escape(err)}</div>' if err else ''
|
||||||
|
return _page('인증 코드', f'''<form class="box" method="post" action="/login/otp">
|
||||||
|
<h1>📱 인증 코드</h1><p>iMessage로 보낸 6자리 코드를 입력하세요 (5분 유효)</p>{e}
|
||||||
|
<input name="otp" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" autofocus required>
|
||||||
|
<button>로그인</button></form>
|
||||||
|
<form method="post" action="/login/request" style="text-align:center;margin-top:14px"><button style="width:auto;background:none;color:var(--muted);font-size:13px">코드 다시 받기</button></form>''')
|
||||||
|
|
||||||
|
|
||||||
|
APP_CSS = CSS + """
|
||||||
|
body{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)}
|
||||||
|
.tabs{display:flex;gap:6px;flex:1;min-width:0}
|
||||||
|
.tab{position:relative;border:0;background:var(--chip);border-radius:18px;padding:6px 12px;white-space:nowrap;cursor:pointer}
|
||||||
|
.tab.on{background:var(--accent);color:#fff}
|
||||||
|
.tab .dot{position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--err);display:none}
|
||||||
|
.tab.unread .dot{display:block}
|
||||||
|
.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;overflow-y:auto;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}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _app_page() -> bytes:
|
||||||
|
agents = json.dumps([{'id': a, 'name': n, 'emoji': e} for a, n, e in AGENTS], ensure_ascii=False)
|
||||||
|
body = f'''<header><div class="tabs" id="tabs"></div>
|
||||||
|
<button class="menu-btn" id="menuBtn" aria-label="메뉴">⋯</button>
|
||||||
|
<div class="menu" id="menu"><button id="resetBtn">새 대화 시작</button>
|
||||||
|
<form method="post" action="/logout" style="margin:0"><button>로그아웃</button></form></div></header>
|
||||||
|
<main id="log"></main>
|
||||||
|
<div class="typing" id="typing"></div>
|
||||||
|
<footer><textarea id="input" rows="1" placeholder="메시지"></textarea>
|
||||||
|
<button id="send" aria-label="전송">➤</button></footer>
|
||||||
|
<script>window.AGENTS={agents};</script><script src="/app.js"></script>'''
|
||||||
|
return _page('채팅', body, APP_CSS)
|
||||||
|
|
||||||
|
|
||||||
|
APP_JS = r"""
|
||||||
|
(function(){
|
||||||
|
const 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,'<code>$1</code>').replace(/\*\*([^*\n]+)\*\*/g,'<b>$1</b>')
|
||||||
|
.replace(/(https?:\/\/[^\s<]+)/g,'<a href="$1" target="_blank" rel="noopener">$1</a>').replace(/\n/g,'<br>');
|
||||||
|
return s.replace(/\u0000(\d+)\u0000/g,(m,i)=>'<pre><code>'+blocks[i]+'</code></pre>');
|
||||||
|
}
|
||||||
|
// 답변 속 "수정해줘"라고 / '네'라고 같은 '따옴표 + (이)라고' 표현을 선택지 버튼으로 뽑는다
|
||||||
|
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(){
|
||||||
|
$('#tabs').innerHTML=A.map(a=>{const st=status[a.id]||{};const un=a.id!==cur&&st.last&&st.last!==lastSeen[a.id];
|
||||||
|
return '<button class="tab'+(a.id===cur?' on':'')+(un?' unread':'')+'" data-id="'+a.id+'">'+a.emoji+' '+a.name+(st.pending?' …':'')+'<span class="dot"></span></button>'}).join('');
|
||||||
|
}
|
||||||
|
function renderLog(items){
|
||||||
|
const a=A.find(x=>x.id===cur);
|
||||||
|
if(!items.length){log.innerHTML='<div class="empty">'+a.emoji+' '+a.name+'에게 말을 걸어보세요</div>';return}
|
||||||
|
log.innerHTML=items.map(m=>m.role==='system'?'<div class="sys">— '+esc(m.text)+' · '+m.ts.slice(5,16).replace('T',' ')+' —</div>'
|
||||||
|
:'<div class="msg '+m.role+'">'+(m.role==='user'?esc(m.text).replace(/\n/g,'<br>'):md(strip(m.text)))+'<span class="t">'+hm(m.ts)+'</span></div>').join('');
|
||||||
|
const last=items[items.length-1], ch=last.role==='agent'?choices(last.text):[];
|
||||||
|
if(ch.length)log.insertAdjacentHTML('beforeend','<div class="choices">'+ch.map(c=>'<button class="choice" data-choice="'+esc(c)+'"'+(send.disabled?' disabled':'')+'>'+esc(c)+'</button>').join('')+'</div>');
|
||||||
|
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'}
|
||||||
|
$('#tabs').addEventListener('click',e=>{const b=e.target.closest('.tab');if(!b)return;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'));
|
||||||
|
$('#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)
|
||||||
|
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__)
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Reference in New Issue
Block a user