From 86e38478dd81aff400a682ee5823abe05e3f7248 Mon Sep 17 00:00:00 2001 From: hyowons Date: Wed, 7 Oct 2026 11:26:03 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9B=B9=20=EC=B1=84=ED=8C=85(chat.hyowons.net?= =?UTF-8?q?)=20=EC=B6=94=EA=B0=80=20=E2=80=94=20=ED=81=B4=EB=A1=9C=C2=B7?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=C2=B7=EA=B3=A8=EB=94=94=EC=99=80=20=EB=8C=80?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 2 + TASKS.md | 2 + workspace/MEMORY.md | 1 + workspace/scripts/chat_web.py | 673 ++++++++++++++++++++++++++++ workspace/scripts/chat_web_icon.png | Bin 0 -> 20150 bytes 5 files changed, 678 insertions(+) create mode 100644 workspace/scripts/chat_web.py create mode 100644 workspace/scripts/chat_web_icon.png diff --git a/CLAUDE.md b/CLAUDE.md index ff4eec8b..489de4ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,6 +93,7 @@ Stock agent skills (`agents/stock/workspace/skills/`): Main workspace (`workspace/scripts/`), run with `python3`: - `briefing_mail.py` — Gmail/Calendar/YouTube 뉴스 브리핑 composer (네이버 지수 KOSPI/KOSDAQ/나스닥 조회 포함, 월요일 오전엔 stock agent의 `ipo_calendar_sync.py` 호출). **오전 브리핑은 `youtube_briefing_digest`를 import해 상단에 🌎 해외 증시·이슈 + 🇰🇷 국내 증시·이슈 카드 2개 삽입**. `prepare morning [--final]`이 `us_market`(@futuresnow)·`behive_market`(비하이브 주식시황) 블록(available_today·recap_expected_today·video·transcript)을 산출. ⚠️ **비하이브 주식시황이 미국증시도 다뤄 @futuresnow와 해외부분 겹침** → LLM이 두 자막을 합쳐 중복제거 후 **해외(`overseas_summary`)/국내(`domestic_summary`)로 재정리**(해외=두 소스 병합, 미국수치는 @futuresnow 우선 / 국내=비하이브 국내부분만). 발송 게이트 `us_send_directive`(proceed/wait): **둘 중 하나라도 available이면 proceed**, 평일(둘 중 하나라도 게시예정)인데 둘 다 미게시면 wait(다음 폴백 재시도), 주말/`--final`은 proceed. cron이 directive==wait면 발송 보류. 카드 요약은 LLM이 카테고리 그룹 배열(`[{label,items[]}]`)로 작성, compose의 `_video_summary_card`가 `overseas_summary`/`domestic_summary` 있을 때만 렌더(해외=파랑·국내=초록 팔레트, 그룹 라벨에 안 맞는 내용은 '기타' 그룹). **오후 브리핑은 `behive_close`(비하이브 '마감시황')로 📕 마감시황 카드**(갈색 팔레트, `close_summary` 그룹배열, best-effort) — 시장 체크 다음·뉴스 앞에 렌더. **요약·뉴스 본문의 `**강조**` 마크다운은 `_emphasize`가 굵게+진한 강조색(글자색 #c62828, 배경 안 건드림)으로 변환** — LLM이 핵심 수치·키워드만 절제해서 표시(시황 카드·뉴스 요약 공통). **국내 증시에 큰 영향 줄 뉴스는 LLM이 article에 `kr_impact:true` → `_article_card`가 🔥'국내증시 영향' 배지+빨강 강조 테두리**로 부각(반도체·환율·미 금리·외국인 수급·정책 등, 남발 금지). **해외/국내/마감시황 카드 불릿도 국내 영향 큰 항목은 LLM이 맨 앞에 `🔥 ` 접두 → `_us_items_html`이 강조 배경 밴드(#fff7f6)로 표시**. **시장 체크의 코스피 야간선물·MSCI는 비하이브 자막 언급값으로 오버라이드** — LLM이 `kospi_futures_override`/`msci_override`(라인 문자열) 작성 시 build_html_body가 스크랩값(investing.com, 전날 것일 수 있음) 대신 사용, 미작성·불명확이면 스크랩 유지. ⚠️ @futuresnow '오늘의 요약'엔 코스피/MSCI 언급 없음(미국 전용) — 이 값 출처는 비하이브뿐. **토요일 = 해외 전용 브리핑**(2026-08-12): `build_prepare_payload`가 `now.weekday()==5`면 `overseas_only=True` → 뉴스는 `OVERSEAS_NEWS_FEEDS`('국제' 2개, 국내경제·증시 제외)만 쓰고 **일정·IPO·내일상장은 수집 자체를 생략**한다. 부수효과 둘: `gog` 3콜이 사라져 유일한 단일 실패점(`get_today_events`는 try/except가 없어 실패 시 prepare 전체 중단)을 토요일엔 회피하고, IPO를 안 읽으니 `mark_ipo_changes_seen`이 seen을 선점하지 않아 **금요일 sync 결과가 월요일 아침에 정상 노출**된다. ⚠️ `build_html_body`에서 일정 카드를 **명시적으로 스킵**해야 한다 — `events`가 빈 배열이어도 `_list_card`는 '일정 없습니다' 카드를 렌더한다. 국내 카드는 `domestic_summary` 조건부라 자동 제외되고, `_morning_missing_summary_notes`의 '비하이브 요약 없음' 각주도 `overseas_only`면 억제(의도된 부재라 노이즈). **뉴스 중복제거 3단**: ①실행 내 정규화 제목 완전일치 ②실행 내 토큰 min-overlap ≥`NEWS_DUP_THRESHOLD`(0.40) ③48h 발송이력 — ③은 오래 **완전일치뿐**이라 '같은 사안 다른 제목'이 오후에 재등장했고, 2026-08-12 `recent_history_token_sets()`로 이력 제목의 토큰 집합을 만들어 `collect_news_articles(exclude_token_sets=)`에 넘겨 ②의 fuzzy를 이력에도 적용했다(실측: 오전 발송분과 같은 사안 2건이 저녁 후보에서 걸러지고 그 자리에 새 기사가 채워짐). ⚠️ **한국어 조사·복합명사 때문에 같은 사안이 0.40 아래로 떨어지는 쌍이 남아 있다**(실측 '트럼프 골프장에 이동식 방공미사일' vs '골프장에 방공 미사일…트럼프의 암살 노이로제' = 0.29). 문턱을 낮추면 오탐이 늘어 손대지 않았다 — 더 줄이려면 토크나이저(조사 절단·문자 n-gram)를 고치는 쪽. ⚠️ `prepare`는 `already_sent`면 `briefing_pending_selection.json`을 **덮어쓰지 않는다**(2026-08-12) — 그전엔 08:30 폴백이 스킵 직전에 기사를 재선별해 발송본과 무관한 목록을 남겼고(실측 발송 7건 vs 파일 4건, 교집합 0), 이 파일이 '같은 뉴스가 또 와?' 디버깅 진입점이라 틀린 증거를 보게 됐다 - `gmail_label_classify.py` — Gmail 라벨 자동 분류 (조회 전용 Gmail API, 계정 `mini.snowoyh@gmail.com`). self-sent 메일을 제목 룰로 라벨링: `[오전/오후 브리핑]`→`브리핑`, `[비하이브 종목분석]`→`종목분석`(`주식` 라벨 제거), `[주식 리포트]`→`주식브리핑`(`주식` 제거) + 24h 지난 테스트메일 휴지통 이동. 로그 `state/gmail_label_classify.log`. launchd `ai.openclaw.gmail-label-classify` 매일 01:00(LLM 미경유, 2026-06-26 cron에서 이관). CLI: `python3 gmail_label_classify.py [--dry-run]` +- `chat_web.py` — **웹 채팅**(2026-10-07). `chat.hyowons.net`에서 클로·레이·골디와 텔레그램처럼 대화하는 자체 웹(표준 라이브러리 단일 파일, 포트 18793, launchd `ai.openclaw.chat-web` 상시). **Tailscale 없이** NAS 리버스 프록시로 공개되므로(와일드카드 DNS라 프록시 규칙만 추가) 로그인은 **본인 iMessage OTP 단독**(`[인증 코드 받기]` → `@chat.hyowons.net #코드` iOS 자동입력, 5분) → 30일 세션 쿠키(HttpOnly·Secure·Lax, 서버엔 sha256만 `state/chat_web/auth.json`). ⚠️ **비밀번호는 없다**(2026-10-07 관리자님 결정 — 처음엔 비밀번호+OTP 2단계로 만들었다가 제거). 그래서 누구나 '코드 받기'를 누를 수 있어 **발송 제한**이 핵심 방어다 — 1분 1회·1시간 5회, 넘으면 15분 잠금. 코드를 15분 안에 5회 틀려도(**IP 무관 전역**) 15분 잠금. 둘 다 잠길 때 + 클로 텔레그램 알림(`codex_fallback_monitor._send_telegram` 재사용). API POST는 세션 + `X-CW: 1` 헤더 필수(교차 출처 요청 차단). 에이전트 호출은 `openclaw agent --agent --session-key web… --message-file … --json`(게이트웨이 경유 — 게이트웨이 자체는 외부에 안 연다). ⚠️ **세션 키가 텔레그램과 달라 웹 대화는 별도 맥락**이고, `--deliver` 미지정이라 답이 텔레그램으로 나가지 않는다(관리자님 선택). '새 대화 시작'은 세션 키를 `web-<시각>`으로 바꾼다(`state/chat_web/session_keys.json`). **명령어**: 슬래시 명령은 그대로 에이전트에 전달돼 OpenClaw 본체가 처리한다(`/new`=본체가 세션 리셋 `✅ New session started.`, `/status`=LLM이 session_status 도구로 답함 — 실측). **`/clear`만 웹 전용**으로 서버가 가로채 화면 기록을 `history/archive/`로 옮기고(삭제 X) 세션 키를 새로 바꾼다(에이전트 호출 없음). 에이전트당 동시 1건(답 기다리는 중 전송은 409). 화면 기록 `state/chat_web/history/.jsonl`(최근 300줄 표시). 클라이언트는 답을 기다릴 때만 2초, 평소엔 20초마다 폴링 — 에이전트가 먼저 말을 거는 경로는 없다(크론 알림은 여전히 텔레그램). ⚠️ OTP·로그인 대기 상태는 메모리에만 있어 서버 재시작 시 로그인 중이던 사람은 처음부터 다시 한다. **선택지 버튼**(2026-10-07, 두 방식 병행 — 관리자님 선택): ①**에이전트 지정** — 웹에서 보내는 메시지에만 `WEB_HINT`("고를 질문이면 마지막 줄에 `[[버튼: A | B]]`")를 덧붙여 에이전트에 전달하고, 클라이언트가 마커를 숨기고 버튼으로 그린다. 화면 기록엔 원문만 저장(안내는 안 보임). ⚠️ **슬래시 명령엔 안내를 붙이지 않는다**(`/new` 등 본체 명령이 깨진다). ⚠️ **AGENTS.md 에 넣지 않은 이유** — 텔레그램엔 버튼이 없어 마커가 날것으로 보이고, 텔레그램 메타 규칙은 부작용 전례가 있다. 실측 골디·클로 모두 형식을 지켰고 마커 위치는 `[답변완료]` 앞뒤가 섞여 나와 어디에 있어도 파싱한다 ②**문장 추출(폴백)** — 마커가 없을 때만, 답변 속 `"수정해줘"라고`·`'취소'라고` 같은 **따옴표+(이)라고** 표현을 `choices()` 가 뽑는다. 누르면 그 문구를 그대로 보낸다(입력창 초안은 안 건드림). ⚠️ **마지막 메시지가 에이전트 답변일 때만** 띄운다 — 지난 답변의 버튼은 이미 끝난 질문이다. 답을 기다리는 동안 버튼은 비활성. **홈 화면 아이콘**은 `chat_web_icon.png`(180px, 같은 폴더)를 `/apple-touch-icon.png` 로 **인증 없이** 서빙한다(iOS 는 이게 없으면 제목 첫 글자 '채'로 아이콘을 만든다). 아이콘을 바꾸면 iOS 는 캐시하므로 홈 화면에서 지우고 다시 추가해야 반영된다 - `youtube_briefing_digest.py` — 오전 브리핑용 유튜브 시황 영상 감지+자막 (두 채널 공용). `get_us_summary()`=오선의 미국 증시 라이브(@futuresnow, `UC_JJ_NhRqPKcIOj5Ko3W_3w`) '오늘의 요약'(미국장 마감 후 07:20~08:30 KST, 예정=화~토) / `get_behive_market_summary()`=비하이브 투자자문(`UCHTRF5r154igU2gXjudUMzg`) '주식시황'(장전 시황, 05:40~06:50 KST, 예정=월~금) / `get_behive_close_summary()`=같은 채널 '마감시황'(장 마감 해설, 16:45~18:10 KST, 예정=월~금, 오후 브리핑용). 공통: 채널 영상 페이지 `lockupViewModel` 스크랩(RSS 피드 IP 404 잦아 미사용)→제목필터→최신영상→watch `publishDate`(PT→KST)로 오늘게시 판정(제목 날짜는 신뢰X)→자막. CLI: `python3 youtube_briefing_digest.py {latest|transcript} [us|behive]`. ⚠️ 레이의 `behive_youtube_digest.py`(종목분석→워치리스트)와 별개 — 같은 채널이지만 목적·소유자 다름 Stock agent (`agents/stock/workspace/scripts/`), run with `python3`: @@ -170,6 +171,7 @@ OpenClaw 자동화는 두 갈래로 동작한다 (모두 Asia/Seoul): - **gateway** — 상시 daemon (포트 18789) - **claude-remote-control** — on-demand daemon (코디 세션, `claude-code-session` 스킬이 띄움) - **stock.behive-web** — 상시 daemon (워치리스트 웹뷰, Tailscale 18790) +- **chat-web** — 상시 daemon (웹 채팅 `chat.hyowons.net`, 포트 18793, `workspace/scripts/chat_web.py serve`) - **stock.briefing** — 평일 20:10 — `stock_portfolio_report.py send --no-mail`: **포트폴리오 스냅샷 확보 전용, 메일 발송 없음**(2026-08-12 관리자님 결정 — 메일 통수 축소). 스냅샷은 자산웹 '당일 평가손익'·`rebase_to_nxt_close` 전일 baseline의 근거라 계속 쌓는다. ⚠️ **`run` 모드로 바꾸지 말 것** — 휴장일 self-skip이 `send` 분기에만 있어 휴장일 키가 이력에 섞이고 평문 리포트가 로그로 쏟아진다. `--no-mail`은 휴장일 가드·실패 자가알림을 유지하고 `send_mail`만 건너뜀 - **stock.briefing-fallback-2030** — 평일 20:30 — 오늘 스냅샷 없으면 `send --no-mail` 재실행 (idempotent). 최근 2주 실측 한 번도 실제 작업 안 함(20:10이 매일 성공) - **stock.briefing-fallback-2100** — 평일 21:00 — **무조건 fresh fetch로 스냅샷 갱신** (`briefing_fallback.py force` → 스냅샷 있으면 `stock_portfolio_report.py run` 메일·텔레그램 X, 없으면 `send --no-mail` 폴백 + 실패 시 알림). 20:10 데이터 부정확 케이스 보완용 diff --git a/TASKS.md b/TASKS.md index fefffadd..27a98c9d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -13,6 +13,7 @@ |---|---|---| | **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/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/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) | @@ -77,6 +78,7 @@ LLM이 자막 요약·뉴스 평문 생성·결산 보고서 등 본문을 만 | OpenClaw Gateway | `daemon:ai.openclaw.gateway` | - | | 비하이브 웹뷰 | `daemon:ai.openclaw.stock.behive-web` | - | | 시뮬 대시보드 | `daemon:ai.openclaw.stock.sim-web` | - | +| 웹 채팅 | `daemon:ai.openclaw.chat-web` | - | | 후잉 동기화 | `logs/whooing-sync.log` | 2h | | codex 폴백 감시 | `logs/codex-fallback-monitor.log` | 2h | | 급등락 알림 | `logs/stock-surge-monitor.log` | 100h | diff --git a/workspace/MEMORY.md b/workspace/MEMORY.md index 66ce4342..fa894c62 100644 --- a/workspace/MEMORY.md +++ b/workspace/MEMORY.md @@ -26,6 +26,7 @@ | 이름 | 일정 (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` | +| 웹 채팅 서버 | 상시 | `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}` | ### 원칙 diff --git a/workspace/scripts/chat_web.py b/workspace/scripts/chat_web.py new file mode 100644 index 00000000..fc82e2a5 --- /dev/null +++ b/workspace/scripts/chat_web.py @@ -0,0 +1,673 @@ +#!/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_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'' + 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 + """ +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'''
+ +
+
+
+
+
+''' + 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,'$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(){ + $('#tabs').innerHTML=A.map(a=>{const st=status[a.id]||{};const un=a.id!==cur&&st.last&&st.last!==lastSeen[a.id]; + return ''}).join(''); +} +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'} +$('#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__) diff --git a/workspace/scripts/chat_web_icon.png b/workspace/scripts/chat_web_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..38e8f9c6f43306fcbec72de9ea6c665431773a3e GIT binary patch literal 20150 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