diff --git a/.gitignore b/.gitignore index 546fad6..680b8c4 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ config/*storage-state*.json config/release-state.json config/release-storage-state.json .env +.env.before-* +browser-profile/ +data/ +logs/ diff --git a/README.md b/README.md index e529807..fa2d030 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,24 @@ uvicorn license_agent.main:app --host 0.0.0.0 --port 8000 http://你的服务器:8000/dingtalk/webhook ``` +## 无公网 IP:启用钉钉 Stream 模式 + +如果部署机器没有公网 IP 或无法暴露 `/dingtalk/webhook`,可以启用钉钉 Stream 模式。Stream 模式由 Agent 主动连接钉钉,接收用户消息不需要公网回调地址。 + +在 `.env` 中配置钉钉开放平台应用凭证: + +```bash +DINGTALK_STREAM_ENABLED=true +DINGTALK_STREAM_CLIENT_ID=你的钉钉应用Client ID或App Key +DINGTALK_STREAM_CLIENT_SECRET=你的钉钉应用Client Secret或App Secret +DINGTALK_STREAM_REPLY_TIMEOUT_SECONDS=3 +DINGTALK_STREAM_ASYNC_SUBMIT=true +``` + +然后按原方式启动服务即可,`tools/run_agent.py` 和 systemd 都会在 FastAPI 启动时同时启动 Stream 长连接。HTTP webhook 会保留,方便有公网地址时继续使用。 + +注意:Stream 只解决“接收钉钉消息不需要公网 IP”。下载完成通知可以继续使用自定义机器人 webhook,也可以把 `DINGTALK_NOTIFY_MODE=openapi` 配成企业内部应用机器人 OpenAPI 单聊通知;但如果通知内容是 `/license-files/{token}` 短期下载链接,`PUBLIC_BASE_URL` 仍然需要是用户能访问的地址。没有公网文件地址时,后续应改接钉钉文件上传或对象存储链接。 + ## Linux 服务器部署 服务器侧脚本和 systemd 模板已经放在 `deploy/` 目录。部署时先在服务器拉取 GitHub 仓库: @@ -84,6 +102,18 @@ uvicorn license_agent.main:app --host 0.0.0.0 --port 8000 `RELEASE_DRY_RUN=true` 时,Agent 会把 Release 表单填到提交前并保存截图,不会点击提交。确认流程稳定后,再改为: +生产部署推荐改用服务器持久 Chromium 目录,避免本地登录态上传后因服务端会话、 +出口 IP 或浏览器环境变化而失效: + +```bash +unset RELEASE_STORAGE_STATE +export RELEASE_USER_DATA_DIR=/AI/lisence-agent/browser-profile +export RELEASE_DATA_DIR=/AI/lisence-agent/data +``` + +首次登录通过 SSH 隧道和仅监听服务器回环地址的 VNC 完成,具体步骤见 +`deploy/README.md`。“storage state 文件”和“持久浏览器目录”只能选择一种。 + ```bash export RELEASE_DRY_RUN=false ``` diff --git a/config/settings.example.env b/config/settings.example.env index 27c7bc3..e667e6d 100644 --- a/config/settings.example.env +++ b/config/settings.example.env @@ -1,3 +1,18 @@ +# DingTalk Stream mode: receive messages without a public callback URL. +DINGTALK_STREAM_ENABLED=false +DINGTALK_STREAM_CLIENT_ID= +DINGTALK_STREAM_CLIENT_SECRET= +DINGTALK_STREAM_REPLY_TIMEOUT_SECONDS=3 +DINGTALK_STREAM_ASYNC_SUBMIT=true + +# DingTalk OpenAPI app robot notification for non-webhook robots. +DINGTALK_NOTIFY_MODE=openapi +DINGTALK_OPENAPI_CLIENT_ID= +DINGTALK_OPENAPI_CLIENT_SECRET= +DINGTALK_OPENAPI_CORP_ID= +DINGTALK_OPENAPI_ROBOT_CODE= + +# DingTalk custom robot webhook for download-finished notifications. Keep this secret. DINGTALK_WEBHOOK_SECRET= DINGTALK_ACCESS_TOKEN= DINGTALK_WEBHOOK_URL= diff --git a/deploy/README.md b/deploy/README.md index bf6c768..322255b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -40,6 +40,45 @@ sudo bash deploy/install_server.sh ## 3. 上传 Release 登录态 +### 推荐:在服务器建立持久浏览器登录态 + +生产环境优先让登录和授权申请都发生在同一台服务器上。服务器无需桌面环境, +只需安装 `Xvfb` 和 `x11vnc`;VNC 仅监听回环地址并通过 SSH 隧道访问。 + +```bash +ssh root@10.2.36.19 'apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y x11vnc' +ssh root@10.2.36.19 'cd /AI/lisence-agent && bash deploy/login_release_vnc.sh' +``` + +脚本会要求设置临时 VNC 密码。随后在 Mac 另开终端建立隧道: + +```bash +ssh -N -L 5901:127.0.0.1:5900 root@10.2.36.19 +``` + +打开 Finder 的“前往服务器”,连接 `vnc://127.0.0.1:5901`。完成钉钉登录并确认 +授权申请表单已经显示后,在服务器停止可视浏览器并立即用全新的无头进程验证: + +```bash +ssh root@10.2.36.19 \ + 'cd /AI/lisence-agent && bash deploy/stop_release_vnc.sh' +``` + +服务器 `.env` 使用以下配置,并删除或注释 `RELEASE_STORAGE_STATE`: + +```bash +RELEASE_USER_DATA_DIR=/AI/lisence-agent/browser-profile +RELEASE_DATA_DIR=/AI/lisence-agent/data +RELEASE_DRY_RUN=true +RELEASE_HEADLESS=true +``` + +`RELEASE_USER_DATA_DIR` 与 `RELEASE_STORAGE_STATE` 不能同时配置。VNC 端口无需、也不应 +加入云防火墙放行规则。登录完成后 VNC 与 Xvfb 都会停止,Agent 日常以无头模式复用 +同一浏览器目录。 + +### 兼容方式:上传 storage state + 在你的本地电脑先重新生成并实时校验登录态: ```bash @@ -91,6 +130,37 @@ DINGTALK_WEBHOOK_URL=https://oapi.dingtalk.com/robot/send?access_token=xxx DINGTALK_WEBHOOK_SECRET=钉钉机器人加签密钥 ``` +如果服务器没有公网 IP,接收钉钉消息可以改用 Stream 模式,不需要配置机器人 HTTP 回调地址: + +```bash +DINGTALK_STREAM_ENABLED=true +DINGTALK_STREAM_CLIENT_ID=你的钉钉应用Client ID或App Key +DINGTALK_STREAM_CLIENT_SECRET=你的钉钉应用Client Secret或App Secret +DINGTALK_STREAM_REPLY_TIMEOUT_SECONDS=3 +DINGTALK_STREAM_ASYNC_SUBMIT=true +``` + +`DINGTALK_STREAM_CLIENT_SECRET` 是 Stream 长连接认证密钥;`DINGTALK_WEBHOOK_SECRET` 是固定机器人 webhook 加签密钥,二者不要混用。 + +下载完成后的短期链接通知有两种模式: + +```bash +# 默认:自定义机器人 webhook 通知 +DINGTALK_NOTIFY_MODE=webhook +DINGTALK_WEBHOOK_URL=https://oapi.dingtalk.com/robot/send?access_token=xxx +DINGTALK_WEBHOOK_SECRET=钉钉机器人加签密钥 + +# Stream/企业内部应用机器人:OpenAPI 单聊通知 +DINGTALK_NOTIFY_MODE=openapi +DINGTALK_OPENAPI_CORP_ID=你的企业Corp ID +DINGTALK_OPENAPI_ROBOT_CODE=你的机器人Code +# 可选;为空时复用 DINGTALK_STREAM_CLIENT_ID / DINGTALK_STREAM_CLIENT_SECRET +DINGTALK_OPENAPI_CLIENT_ID= +DINGTALK_OPENAPI_CLIENT_SECRET= +``` + +无论使用哪种通知模式,短期链接里的 `PUBLIC_BASE_URL` 都需要用户能访问。没有公网文件地址时,后续应改接钉钉文件上传或对象存储链接。 + 生成下载链接密钥: ```bash @@ -140,7 +210,7 @@ curl http://127.0.0.1:8000/health http://你的服务器公网IP:8000/dingtalk/webhook ``` -如果前面接了 HTTPS 域名或反向代理,使用你的公网域名地址。 +如果前面接了 HTTPS 域名或反向代理,使用你的公网域名地址。没有公网地址时,启用上面的 Stream 模式即可,不需要填写 HTTP 回调地址。 ## 7. 更新代码 diff --git a/deploy/agent.env.example b/deploy/agent.env.example index bfc27db..63365e5 100644 --- a/deploy/agent.env.example +++ b/deploy/agent.env.example @@ -9,6 +9,10 @@ LICENSE_EXECUTOR=web RELEASE_DRY_RUN=true RELEASE_HEADLESS=true RELEASE_STORAGE_STATE=/opt/license-agent/secrets/release-storage-state.json +# Production alternative: keep login and automation on the same server. +# Do not configure this together with RELEASE_STORAGE_STATE. +# RELEASE_USER_DATA_DIR=/AI/lisence-agent/browser-profile +RELEASE_DATA_DIR=/AI/lisence-agent/data RELEASE_SCREENSHOT_DIR=/opt/license-agent/traces RELEASE_LICENSE_BASE_URL=https://release.chaitin.net/license RELEASE_DOWNLOAD_DIR=/opt/license-agent/downloads @@ -30,7 +34,24 @@ PUBLIC_BASE_URL= DOWNLOAD_TOKEN_SECRET= DOWNLOAD_LINK_TTL_SECONDS=86400 -# DingTalk robot webhook. Keep this secret. +# DingTalk Stream mode: receive messages without a public callback URL. +DINGTALK_STREAM_ENABLED=false +DINGTALK_STREAM_CLIENT_ID= +DINGTALK_STREAM_CLIENT_SECRET= +DINGTALK_STREAM_REPLY_TIMEOUT_SECONDS=3 +DINGTALK_STREAM_ASYNC_SUBMIT=true + +# Download-finished notification mode: webhook or openapi. +DINGTALK_NOTIFY_MODE=webhook + +# DingTalk app robot OpenAPI notification for Stream/non-webhook robots. +# If these are empty, client id/secret fall back to DINGTALK_STREAM_CLIENT_ID/SECRET. +DINGTALK_OPENAPI_CLIENT_ID= +DINGTALK_OPENAPI_CLIENT_SECRET= +DINGTALK_OPENAPI_CORP_ID= +DINGTALK_OPENAPI_ROBOT_CODE= + +# DingTalk custom robot webhook for download-finished notifications. Keep this secret. DINGTALK_WEBHOOK_URL= DINGTALK_WEBHOOK_SECRET= diff --git a/deploy/check_server.sh b/deploy/check_server.sh index 15a9c7e..26c9ee0 100755 --- a/deploy/check_server.sh +++ b/deploy/check_server.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -APP_DIR="${APP_DIR:-/opt/license-agent}" +APP_DIR="${APP_DIR:-/AI/lisence-agent}" ENV_FILE="${ENV_FILE:-$APP_DIR/.env}" if [[ ! -f "$ENV_FILE" ]]; then @@ -16,14 +16,24 @@ set +a echo "Checking directories..." test -d "${RELEASE_SCREENSHOT_DIR:-$APP_DIR/traces}" test -d "${RELEASE_DOWNLOAD_DIR:-$APP_DIR/downloads}" -test -f "${RELEASE_STORAGE_STATE:?RELEASE_STORAGE_STATE is required}" +if [[ -n "${RELEASE_USER_DATA_DIR:-}" ]]; then + test -d "$RELEASE_USER_DATA_DIR" + LOGIN_ARGS=(--user-data-dir "$RELEASE_USER_DATA_DIR") +elif [[ -n "${RELEASE_STORAGE_STATE:-}" ]]; then + test -f "$RELEASE_STORAGE_STATE" + LOGIN_ARGS=(--storage "$RELEASE_STORAGE_STATE") +else + echo "Set RELEASE_USER_DATA_DIR or RELEASE_STORAGE_STATE" + exit 1 +fi echo "Checking Python package..." "$APP_DIR/.venv/bin/python" -m unittest discover "$APP_DIR/tests" echo "Checking Release login state..." "$APP_DIR/.venv/bin/python" "$APP_DIR/tools/check_release_login.py" \ - --storage "$RELEASE_STORAGE_STATE" \ + "${LOGIN_ARGS[@]}" \ + --url "${AUTH_WEB_BASE_URL:-https://release.chaitin.net/license/apply}" \ --live echo "Checking health endpoint..." diff --git a/deploy/login_release_vnc.sh b/deploy/login_release_vnc.sh new file mode 100755 index 0000000..0cd64fb --- /dev/null +++ b/deploy/login_release_vnc.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_DIR="${APP_DIR:-/AI/lisence-agent}" +DISPLAY_NUMBER="${DISPLAY_NUMBER:-99}" +VNC_PORT="${VNC_PORT:-5900}" +RELEASE_USER_DATA_DIR="${RELEASE_USER_DATA_DIR:-$APP_DIR/browser-profile}" +RUNTIME_DIR="${RELEASE_LOGIN_RUNTIME_DIR:-$APP_DIR/data/release-login}" +DISPLAY=":$DISPLAY_NUMBER" + +for command_name in Xvfb x11vnc; do + command -v "$command_name" >/dev/null || { + echo "Missing command: $command_name" + exit 1 + } +done + +if [[ -f "$APP_DIR/logs/agent.pid" ]] && kill -0 "$(cat "$APP_DIR/logs/agent.pid")" 2>/dev/null; then + echo "Stop license-agent before interactive login: $(cat "$APP_DIR/logs/agent.pid")" + exit 1 +fi + +mkdir -p "$RELEASE_USER_DATA_DIR" "$RUNTIME_DIR" +chmod 700 "$RELEASE_USER_DATA_DIR" "$RUNTIME_DIR" + +PASSWORD_FILE="$RUNTIME_DIR/vnc.pass" +if [[ ! -f "$PASSWORD_FILE" ]]; then + read -r -s -p "Set a temporary VNC password: " VNC_PASSWORD + echo + [[ -n "$VNC_PASSWORD" ]] || { echo "VNC password cannot be empty"; exit 1; } + x11vnc -storepasswd "$VNC_PASSWORD" "$PASSWORD_FILE" >/dev/null + unset VNC_PASSWORD +fi +chmod 600 "$PASSWORD_FILE" + +Xvfb "$DISPLAY" -screen 0 1440x900x24 -nolisten tcp \ + >"$RUNTIME_DIR/xvfb.log" 2>&1 & +echo "$!" >"$RUNTIME_DIR/xvfb.pid" +sleep 1 + +DISPLAY="$DISPLAY" "$APP_DIR/.venv/bin/python" "$APP_DIR/tools/open_release_profile.py" \ + --user-data-dir "$RELEASE_USER_DATA_DIR" \ + >"$RUNTIME_DIR/browser.log" 2>&1 & +echo "$!" >"$RUNTIME_DIR/browser.pid" +sleep 2 + +x11vnc -display "$DISPLAY" -localhost -rfbport "$VNC_PORT" \ + -rfbauth "$PASSWORD_FILE" -forever -shared \ + >"$RUNTIME_DIR/x11vnc.log" 2>&1 & +echo "$!" >"$RUNTIME_DIR/x11vnc.pid" + +echo "Release login browser started." +echo "On your Mac run: ssh -N -L 5901:127.0.0.1:$VNC_PORT root@10.2.36.19" +echo "Then open: vnc://127.0.0.1:5901" +echo "After login, run: bash $APP_DIR/deploy/stop_release_vnc.sh" diff --git a/deploy/stop_release_vnc.sh b/deploy/stop_release_vnc.sh new file mode 100755 index 0000000..d2a25ee --- /dev/null +++ b/deploy/stop_release_vnc.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_DIR="${APP_DIR:-/AI/lisence-agent}" +RELEASE_USER_DATA_DIR="${RELEASE_USER_DATA_DIR:-$APP_DIR/browser-profile}" +RUNTIME_DIR="${RELEASE_LOGIN_RUNTIME_DIR:-$APP_DIR/data/release-login}" + +stop_process() { + local name="$1" + local pid_file="$RUNTIME_DIR/$name.pid" + if [[ -f "$pid_file" ]]; then + local pid + pid="$(cat "$pid_file")" + if kill -0 "$pid" 2>/dev/null; then + kill "$pid" + for _ in {1..20}; do + kill -0 "$pid" 2>/dev/null || break + sleep 0.25 + done + fi + rm -f "$pid_file" + fi +} + +stop_process browser +stop_process x11vnc +stop_process xvfb + +RELEASE_DATA_DIR="${RELEASE_DATA_DIR:-$APP_DIR/data}" \ + "$APP_DIR/.venv/bin/python" "$APP_DIR/tools/check_release_login.py" \ + --user-data-dir "$RELEASE_USER_DATA_DIR" \ + --url "${AUTH_WEB_BASE_URL:-https://release.chaitin.net/license/apply}" \ + --live diff --git a/docs/superpowers/plans/2026-07-12-live-request-fixes.md b/docs/superpowers/plans/2026-07-12-live-request-fixes.md new file mode 100644 index 0000000..165cc75 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-live-request-fixes.md @@ -0,0 +1,257 @@ +# Live Request Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Parse realistic one-line DingTalk license requests and reliably select the current Release product option through dry-run automation. + +**Architecture:** Build field-pair recognition from `ProductCatalog.alias_to_field` so natural-language prefixes cannot become keys. Isolate Release option-label normalization and unique-match validation in pure helpers, then let the Playwright selector collect visible option-like elements and click the one resolved helper result. + +**Tech Stack:** Python 3.11+, `re`, `unittest`, Playwright async API, FastAPI webhook, Chromium persistent profile. + +## Global Constraints + +- Keep `RELEASE_DRY_RUN=true` during implementation and server acceptance. +- Do not print cookies, local storage, CRM search results, or browser-profile contents. +- Do not change conversation state, submission policy, profile storage, download monitoring, or input indices. +- Missing or ambiguous Release options must fail before the submit button is clicked. +- Server acceptance must use the same realistic request with a fresh conversation ID. + +--- + +### Task 1: Parse Known Fields Inside a Realistic One-Line Request + +**Files:** +- Modify: `tests/test_parser.py` +- Modify: `license_agent/parser.py` + +**Interfaces:** +- Consumes: `ProductCatalog.alias_to_field: dict[str, str]`. +- Produces: `parse_message(text: str, catalog: ProductCatalog) -> dict[str, str]` with unchanged signature and correctly bounded known field values. + +- [ ] **Step 1: Add the failing realistic request test** + +Add to `ParserTest`: + +```python +def test_parses_realistic_one_line_request(self) -> None: + fields = parse_message( + "申请雷池20系列测试授权 CRMID=67fce80803460ad9f6a7bbd9 " + "机器码=UAHD-PE7X-7P47-FS4O 申请目的=POC 使用场景=新签 期限=30", + self.catalog, + ) + + self.assertEqual(fields["product"], "雷池20系列") + self.assertEqual(fields["crm_id"], "67fce80803460ad9f6a7bbd9") + self.assertEqual(fields["machine_code"], "UAHD-PE7X-7P47-FS4O") + self.assertEqual(fields["license_purpose"], "POC") + self.assertEqual(fields["usage_scenario"], "新签") + self.assertEqual(fields["duration_days"], "30") +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +.venv/bin/python -m unittest tests.test_parser.ParserTest.test_parses_realistic_one_line_request -v +``` + +Expected: FAIL because `crm_id` is absent. + +- [ ] **Step 3: Build the pair regex from known aliases** + +Replace the module-level generic pair regex with: + +```python +FIELD_SEPARATOR = r"\s*[::=]\s*" + + +def _pair_pattern(catalog: ProductCatalog) -> re.Pattern[str]: + aliases = sorted(catalog.alias_to_field, key=len, reverse=True) + known_key = "|".join(re.escape(alias) for alias in aliases) + return re.compile( + rf"(?{known_key}){FIELD_SEPARATOR}" + rf"(?P.*?)(?=(?:\s+|[,,;;]\s*)" + rf"(?:{known_key}){FIELD_SEPARATOR}|[\n\r]|$)", + re.IGNORECASE, + ) +``` + +In `parse_message`, iterate over `_pair_pattern(catalog).finditer(text)` and keep the existing alias lookup, duration normalization, and product detection. + +- [ ] **Step 4: Run parser tests and verify GREEN** + +Run: + +```bash +.venv/bin/python -m unittest tests.test_parser -v +``` + +Expected: all parser tests pass. + +- [ ] **Step 5: Commit the parser fix** + +```bash +git add license_agent/parser.py tests/test_parser.py +git commit -m "Parse known fields in conversational requests" +``` + +### Task 2: Resolve Release Options by Normalized Visible Labels + +**Files:** +- Modify: `tests/test_release_page.py` +- Modify: `license_agent/release_page.py` + +**Interfaces:** +- Produces: `normalize_option_label(value: str) -> str`. +- Produces: `resolve_option_index(labels: Sequence[str], target: str) -> int`, raising `RuntimeError` unless exactly one normalized label matches. +- Preserves: `select_md_option(page, input_index: int, option_text: str) -> None`. + +- [ ] **Step 1: Add failing pure-helper tests** + +Import `normalize_option_label` and `resolve_option_index`, then add: + +```python +def test_normalizes_release_option_whitespace(self) -> None: + self.assertEqual(normalize_option_label(" 雷池 20 系列\n"), "雷池20系列") + +def test_resolves_one_normalized_option(self) -> None: + self.assertEqual( + resolve_option_index(["其他产品", "雷池20系列"], "雷池 20 系列"), + 1, + ) + +def test_rejects_missing_or_ambiguous_options(self) -> None: + with self.assertRaisesRegex(RuntimeError, "未找到下拉选项"): + resolve_option_index(["其他产品"], "雷池 20 系列") + with self.assertRaisesRegex(RuntimeError, "匹配到多个下拉选项"): + resolve_option_index(["雷池20系列", "雷池 20 系列"], "雷池20系列") +``` + +- [ ] **Step 2: Run helper tests and verify RED** + +Run: + +```bash +.venv/bin/python -m unittest tests.test_release_page -v +``` + +Expected: import errors because the helpers do not exist. + +- [ ] **Step 3: Inspect live option structure without sensitive data** + +On the server, open `/license/apply`, click input 0, and print JSON containing only each visible candidate's `tag`, `className`, `role`, and normalized `text`. Do not print input values, cookies, storage, or network payloads. Use the evidence to choose candidate selectors including semantic roles and observed Release classes. + +- [ ] **Step 4: Implement normalized unique matching** + +Add: + +```python +def normalize_option_label(value: str) -> str: + return "".join(value.split()).casefold() + + +def resolve_option_index(labels: Sequence[str], target: str) -> int: + normalized_target = normalize_option_label(target) + matches = [ + index + for index, label in enumerate(labels) + if normalize_option_label(label) == normalized_target + ] + if not matches: + available = "、".join(label.strip() for label in labels if label.strip()) + raise RuntimeError(f"未找到下拉选项:{target};当前可见选项:{available or '无'}") + if len(matches) > 1: + raise RuntimeError(f"匹配到多个下拉选项:{target}") + return matches[0] +``` + +Update `select_md_option` to click the input, wait for the visible menu, collect candidate `inner_text()` values, call `resolve_option_index`, click the resolved candidate, and retain the post-click wait. Restrict candidates to visible option-like elements supported by the live DOM evidence. + +- [ ] **Step 5: Run Release page tests and verify GREEN** + +Run: + +```bash +.venv/bin/python -m unittest tests.test_release_page -v +``` + +Expected: all Release page tests pass. + +- [ ] **Step 6: Run the complete local verification suite** + +Run: + +```bash +.venv/bin/python -m unittest discover -s tests -v +.venv/bin/python -m compileall -q license_agent tools tests +git diff --check +``` + +Expected: all tests pass and both follow-up commands exit 0. + +- [ ] **Step 7: Commit the Release selector fix** + +```bash +git add license_agent/release_page.py tests/test_release_page.py +git commit -m "Select Release products by normalized labels" +``` + +### Task 3: Deploy and Repeat the Controlled Server Dry Run + +**Files:** +- Deploy modified tracked files to `/AI/lisence-agent` through a verified Git bundle. +- Generate: `/AI/lisence-agent/traces/WEB-.png` as runtime evidence. + +**Interfaces:** +- Consumes: `POST /dingtalk/webhook`, persistent Chromium profile, and `RELEASE_DRY_RUN=true`. +- Produces: a pre-submit response containing a preview screenshot path without an application ID or pending-download task. + +- [ ] **Step 1: Bundle and deploy commits** + +Create a bundle whose `main` ref contains the implementation commits and requires the server's known base commit. Verify it locally, copy it with `scp`, then on the server run `git fetch main` and `git merge --ff-only FETCH_HEAD`. + +- [ ] **Step 2: Run remote tests before restarting** + +Run on the server: + +```bash +.venv/bin/python -m unittest discover -s tests -v +.venv/bin/python -m compileall -q license_agent tools tests +git diff --check +``` + +Expected: all tests pass and both checks exit 0. + +- [ ] **Step 3: Restart and verify runtime safety settings** + +Stop the existing PID through the deployment script's normal mechanism, start with `deploy/start_no_systemd.sh`, then verify: + +```bash +grep -qx 'RELEASE_DRY_RUN=true' .env +curl -fsS http://127.0.0.1:8000/health +``` + +Expected: dry run remains enabled and health returns `{"status":"ok"}`. + +- [ ] **Step 4: Send the realistic request under a fresh conversation ID** + +POST: + +```text +申请雷池20系列测试授权 CRMID=67fce80803460ad9f6a7bbd9 机器码=UAHD-PE7X-7P47-FS4O 申请目的=POC 使用场景=新签 期限=30 +``` + +Expected: first response says `信息已完整,准备提交` and does not ask for CRMID. + +- [ ] **Step 5: Send `提交` and verify pre-submit completion** + +Expected response includes `已填到提交前,等待人工确认后再提交` and a `traces/WEB-.png` path. Verify the file is newly created and non-empty, no application ID is returned, and no pending-download task is added. + +- [ ] **Step 6: Inspect the preview screenshot and final service state** + +Copy the new screenshot locally and visually verify the selected product, purpose, scenario, device count, feature selections, and visible submit button. Recheck `/health`, the service PID, and `RELEASE_DRY_RUN=true`. + +- [ ] **Step 7: Record final evidence** + +Report the two webhook responses, screenshot path, test count, deployed commit, health result, and explicit confirmation that no real submission occurred. diff --git a/docs/superpowers/plans/2026-07-12-server-browser-profile.md b/docs/superpowers/plans/2026-07-12-server-browser-profile.md new file mode 100644 index 0000000..86d4449 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-server-browser-profile.md @@ -0,0 +1,250 @@ +# Server Browser Profile Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Run Release login, validation, license application, and download with one persistent Chromium profile hosted on `/AI/lisence-agent` at `10.2.36.19`. + +**Architecture:** A shared browser-session module selects exactly one authentication source: Playwright `storage_state` or a persistent `user_data_dir`. It serializes persistent-profile access with an OS file lock and is used by validation, application, and download paths. A maintenance script starts Xvfb, loopback-only x11vnc, and headed Chromium for operator login, then validates the same profile headlessly before the no-systemd agent is restarted. + +**Tech Stack:** Python 3.12, Playwright 1.61, `fcntl`, Bash, Xvfb, x11vnc, SSH local forwarding, unittest. + +## Global Constraints + +- VNC must listen only on server `127.0.0.1`; no cloud firewall port is opened. +- The server deployment root is `/AI/lisence-agent` and PID 1 is `firecracker-init`; do not depend on systemd. +- The profile is `/AI/lisence-agent/browser-profile`, mode `0700`, and is never copied, logged, or committed. +- `RELEASE_USER_DATA_DIR` and `RELEASE_STORAGE_STATE` are mutually exclusive. +- Real submission stays disabled until the dry-run screenshot is reviewed. +- Keep `storage_state` support for compatibility. + +--- + +### Task 1: Shared Browser Session and Profile Lock + +**Files:** +- Create: `license_agent/browser_session.py` +- Create: `tests/test_browser_session.py` + +**Interfaces:** +- Produces: `BrowserAuthConfig.from_values(storage_state_path: str | None, user_data_dir: str | Path | None) -> BrowserAuthConfig` +- Produces: `BrowserSession(playwright, auth, headless, lock_dir).open()`, an async context manager yielding `(browser_context, browser_or_none)`. +- Produces: `ProfileInUseError` for non-blocking lock contention. + +- [ ] **Step 1: Write failing authentication-selection tests** + +Test that storage state alone and user data directory alone are accepted, neither means an anonymous ephemeral context, and both raise `ValueError` containing both environment variable names. + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run: `.venv/bin/python -m unittest tests.test_browser_session -v` + +Expected: import failure because `license_agent.browser_session` does not exist. + +- [ ] **Step 3: Implement immutable authentication configuration** + +Create a frozen dataclass with normalized `Path | None` fields and explicit conflict validation. Do not read environment variables inside this module. + +- [ ] **Step 4: Write failing profile-lock and launch-path tests** + +Use temporary directories and lightweight fake Playwright objects. Assert persistent mode calls `chromium.launch_persistent_context(user_data_dir=..., headless=...)`; storage-state mode calls `chromium.launch()` followed by `browser.new_context(storage_state=...)`; two persistent sessions for the same profile cause `ProfileInUseError`. + +- [ ] **Step 5: Run tests and verify the new tests fail for missing behavior** + +Run: `.venv/bin/python -m unittest tests.test_browser_session -v` + +Expected: configuration tests pass; launch and lock tests fail. + +- [ ] **Step 6: Implement the async session context manager and lock** + +Use `fcntl.flock(fd, LOCK_EX | LOCK_NB)` on `/release-browser-.lock`. Close persistent contexts directly; for ephemeral mode close context then browser. Always release the descriptor in `finally`. + +- [ ] **Step 7: Run focused and full tests** + +Run: `.venv/bin/python -m unittest tests.test_browser_session -v` + +Expected: all browser-session tests pass. + +Run: `.venv/bin/python -m unittest discover tests` + +Expected: all existing tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add license_agent/browser_session.py tests/test_browser_session.py +git commit -m "Add persistent Release browser sessions" +``` + +### Task 2: Use One Session Path for Validation, Application, and Download + +**Files:** +- Modify: `license_agent/login_state.py` +- Modify: `license_agent/executor.py` +- Modify: `license_agent/download.py` +- Modify: `tools/check_release_login.py` +- Modify: `tests/test_login_state.py` +- Modify: relevant executor and downloader tests discovered by `rg --files tests` + +**Interfaces:** +- Consumes: `BrowserAuthConfig`, `BrowserSession`, and `ProfileInUseError` from Task 1. +- Changes: `check_live_login_state(auth: BrowserAuthConfig, url: str, *, headless: bool = True, lock_dir: str | Path = "data") -> LiveLoginCheck`. +- Produces: `LiveLoginCheck` that is valid only when host and page marker both identify the application page. + +- [ ] **Step 1: Write failing page-validation tests** + +Add pure helper tests for these cases: Release application URL plus expected form marker is valid; login URL is invalid; unrelated Release page without the application marker is invalid; authentication-error text is invalid. + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run: `.venv/bin/python -m unittest tests.test_login_state -v` + +Expected: fail because page-marker validation is not implemented. + +- [ ] **Step 3: Implement strict application-page validation** + +Require hostname `release.chaitin.net`, reject `is_login_url`, and require a stable marker already used by `release_page.py` such as the application form heading or submit control. Return a diagnostic reason without cookie values. + +- [ ] **Step 4: Write failing integration-unit tests for auth-source wiring** + +Assert executor and downloader `from_env()` accept `RELEASE_USER_DATA_DIR`, reject both auth variables, and pass their `BrowserAuthConfig` into the shared session. Assert the check CLI accepts `--user-data-dir`, makes it mutually exclusive with `--storage`, and defaults the lock directory from `RELEASE_DATA_DIR` or `data`. + +- [ ] **Step 5: Run focused tests and verify RED** + +Run the exact affected test modules with `.venv/bin/python -m unittest ... -v`. + +Expected: failures show missing user-data-directory parameters and old direct launch calls. + +- [ ] **Step 6: Replace direct Chromium launch code** + +Use `BrowserSession` in `check_live_login_state`, `WebLicenseExecutor._submit_async`, and `WebLicenseDownloader._download_async`. Add `user_data_dir` and `lock_dir` constructor fields and environment mapping. Preserve storage-state behavior. + +- [ ] **Step 7: Update the check CLI** + +Make `--storage` and `--user-data-dir` a required mutually exclusive group for live checks. Cookie summary output remains available only for storage-state mode. Print profile path, final URL, title, and failure reason for persistent mode. + +- [ ] **Step 8: Run all tests** + +Run: `.venv/bin/python -m unittest discover tests` + +Expected: all tests pass. + +- [ ] **Step 9: Commit** + +```bash +git add license_agent/login_state.py license_agent/executor.py license_agent/download.py tools/check_release_login.py tests +git commit -m "Use persistent profile for Release automation" +``` + +### Task 3: Secure No-systemd Interactive Login Tooling + +**Files:** +- Create: `deploy/login_release_vnc.sh` +- Create: `deploy/stop_release_vnc.sh` +- Modify: `deploy/check_server.sh` +- Modify: `deploy/agent.env.example` +- Modify: `deploy/README.md` +- Modify: `README.md` +- Test: `tests/test_deploy_scripts.py` + +**Interfaces:** +- Consumes: `tools/check_release_login.py --user-data-dir` from Task 2. +- Produces: `login_release_vnc.sh` with `APP_DIR`, `DISPLAY_NUMBER`, `VNC_PORT`, and `RELEASE_USER_DATA_DIR` overrides. +- Produces: runtime PID files under `$APP_DIR/data/release-login/`. + +- [ ] **Step 1: Write failing static security tests** + +Read the shell scripts as text and assert the start script uses `x11vnc -localhost`, profile mode `chmod 700`, a VNC password file, PID files, and `/AI/lisence-agent` default; assert no `-nopw` or `0.0.0.0` appears. Test that `check_server.sh` selects `--user-data-dir` when configured. + +- [ ] **Step 2: Run deploy-script tests and verify RED** + +Run: `.venv/bin/python -m unittest tests.test_deploy_scripts -v` + +Expected: fail because the scripts do not exist. + +- [ ] **Step 3: Implement start and stop scripts** + +The start script checks for `Xvfb`, `x11vnc`, and Playwright Chromium; refuses to run while the agent PID is alive; creates the profile and runtime directories with mode `0700`; creates or reuses a mode `0600` x11vnc password file; starts Xvfb, headed Playwright Chromium with `launch_persistent_context`, and x11vnc bound to loopback; prints `ssh -N -L 5901:127.0.0.1: root@10.2.36.19`. The stop script terminates recorded processes gracefully, then runs the headless profile check. + +- [ ] **Step 4: Update environment, checks, and documentation** + +Document `RELEASE_USER_DATA_DIR=/AI/lisence-agent/browser-profile` and `RELEASE_DATA_DIR=/AI/lisence-agent/data`. Update `check_server.sh` for either auth source and no-systemd health checks. Document Mac Screen Sharing connection to `vnc://127.0.0.1:5901`, interactive login, stop-and-validate, dry run, and recovery. + +- [ ] **Step 5: Run shell syntax and tests** + +Run: `bash -n deploy/login_release_vnc.sh deploy/stop_release_vnc.sh deploy/check_server.sh deploy/start_no_systemd.sh` + +Expected: exit 0. + +Run: `.venv/bin/python -m unittest discover tests` + +Expected: all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add deploy README.md tests/test_deploy_scripts.py +git commit -m "Add secure server-side Release login flow" +``` + +### Task 4: Deploy and Perform Server Acceptance + +**Files:** +- Modify remotely: `/AI/lisence-agent/.env` (preserve secrets and make a timestamped mode-0600 backup) +- Create remotely: `/AI/lisence-agent/browser-profile/` +- Create remotely: `/AI/lisence-agent/data/release-login/` + +**Interfaces:** +- Consumes: all code and scripts from Tasks 1-3. +- Produces: validated server-hosted Chromium profile and dry-run-ready agent configuration. + +- [ ] **Step 1: Verify local branch and tests before deployment** + +Run: `git status --short --branch` + +Expected: clean branch with only the planned commits. + +Run: `.venv/bin/python -m unittest discover tests` + +Expected: all tests pass. + +- [ ] **Step 2: Install the missing server package** + +Run over SSH: `apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y x11vnc` + +Expected: `command -v x11vnc` prints `/usr/bin/x11vnc`. `Xvfb` is already installed. + +- [ ] **Step 3: Deploy tracked code without overwriting secrets or profile data** + +Push commits to the configured origin, then update `/AI/lisence-agent` using its existing deployment mechanism. Verify `.env`, `config/release-storage-state.json`, downloads, traces, data, and browser-profile are unchanged by deployment. + +- [ ] **Step 4: Configure persistent profile mode safely** + +Back up `.env` with mode `0600`; remove only `RELEASE_STORAGE_STATE`; set `RELEASE_USER_DATA_DIR=/AI/lisence-agent/browser-profile`, `RELEASE_DATA_DIR=/AI/lisence-agent/data`, `RELEASE_DRY_RUN=true`, and `RELEASE_HEADLESS=true`. Do not print the full environment file. + +- [ ] **Step 5: Start interactive login and establish SSH tunnel** + +Run: `ssh root@10.2.36.19 'cd /AI/lisence-agent && bash deploy/login_release_vnc.sh'`. + +On the Mac run the printed tunnel command and open `vnc://127.0.0.1:5901`. The operator completes DingTalk login and confirms the Release application form is visible. + +- [ ] **Step 6: Stop interactive mode and prove fresh headless reuse** + +Run: `ssh root@10.2.36.19 'cd /AI/lisence-agent && bash deploy/stop_release_vnc.sh'`. + +Expected: exit 0, final URL under `https://release.chaitin.net/license/apply`, expected application marker found, and no redirect to `auth.chaitin.net/login`. + +- [ ] **Step 7: Run server tests and dry-run acceptance** + +Run server unit tests, `deploy/check_server.sh`, and one controlled dry-run application using non-sensitive test fields approved by the operator. Expected: preview screenshot written under `/AI/lisence-agent/traces`; no submit click occurs. + +- [ ] **Step 8: Restart no-systemd agent and recheck** + +Use the existing PID file to stop the old process gracefully, then run `deploy/start_no_systemd.sh`. Verify `/health`, run a second headless profile check, and confirm the profile lock is released. + +- [ ] **Step 9: Review evidence before enabling submission** + +Review final URL, validation output, health output, and dry-run screenshot. Keep `RELEASE_DRY_RUN=true` unless the operator explicitly authorizes changing it to `false`. + +- [ ] **Step 10: Commit any deployment-specific documentation corrections** + +If server reality required tracked documentation changes, test and commit only those changes. Never commit `.env`, browser profile data, VNC password, storage state, screenshots, downloads, or PID files. diff --git a/docs/superpowers/specs/2026-07-12-live-request-fixes-design.md b/docs/superpowers/specs/2026-07-12-live-request-fixes-design.md new file mode 100644 index 0000000..6e2fdc5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-live-request-fixes-design.md @@ -0,0 +1,73 @@ +# Live Request Parsing and Release Selection Fixes + +## Context + +A server-side dry run using a realistic DingTalk request exposed two failures: + +1. `CRMID=...` was not parsed when it followed natural-language request text. +2. Release opened successfully with the persistent login profile, but product + selection failed because the automation expected a fixed menu class and an + exact spaced label (`雷池 20 系列`). + +Real submission remains disabled with `RELEASE_DRY_RUN=true` throughout the +implementation and acceptance test. + +## Scope + +The change is limited to message field extraction and Release select-option +matching. It does not change conversation state, submission policy, profile +storage, download monitoring, or the production dry-run setting. + +## Message Parsing + +Build the key/value matcher from aliases in `ProductCatalog` instead of using a +generic word pattern. Known aliases are matched case-insensitively, with longer +aliases first. A value ends at a newline, the end of the message, or the next +known alias followed by `:`, `:`, or `=`. + +This allows conversational prefixes such as `申请雷池20系列测试授权` to remain +outside the key while preserving support for compact multi-field requests. +Duration normalization and product alias detection remain unchanged. + +## Release Product Selection + +Before modifying the selector, inspect the live page after opening the first +product control and record only non-sensitive structural evidence: visible +option text, roles, classes, and element tags. Do not print cookies, local +storage, CRM results, or browser-profile contents. + +Replace the fixed `.md-select-menu .md-list-item-button` dependency with a +helper that searches visible option-like elements and compares normalized +labels. Normalization removes whitespace and case differences, so +`雷池 20 系列` and `雷池20系列` match. The helper must reject ambiguous matches +and report the available visible labels when no unique option is found. + +The input index remains unchanged in this fix because the observed failure is +inside option discovery, not control discovery. Broader form refactoring is out +of scope. + +## Error Handling + +- Unknown message keys are ignored as before. +- A select option must resolve to exactly one visible element. +- Missing or ambiguous options return a clear automation error and never click + the submit button. +- Any dry-run failure leaves the service healthy and creates no pending + download task. + +## Tests and Acceptance + +Automated tests will cover: + +- The exact realistic one-line request used in server testing, including its + CRMID, machine code, purpose, scenario, and duration. +- Option label normalization across whitespace variants. +- Unique, missing, and ambiguous option resolution independently from + Playwright where practical. +- The complete existing unit-test suite and Python compilation. + +Server acceptance repeats the same webhook conversation with a new conversation +ID. Success requires a complete first response without a CRMID follow-up, a +`提交` response stating that the form was filled to the pre-submit stage, and a +new preview screenshot. The final URL must remain on Release, the service health +endpoint must pass, and `RELEASE_DRY_RUN=true` must remain configured. diff --git a/docs/superpowers/specs/2026-07-12-server-browser-profile-design.md b/docs/superpowers/specs/2026-07-12-server-browser-profile-design.md new file mode 100644 index 0000000..92ed21b --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-server-browser-profile-design.md @@ -0,0 +1,115 @@ +# Server-hosted Release browser profile + +## Goal + +Keep the Release login and all later license operations in the same Linux +server environment. The operator completes interactive DingTalk login through +an SSH-only remote display, then the agent reuses the same persistent Chromium +profile in headless mode for login checks, applications, and downloads. + +Success means: + +- the server can open `https://release.chaitin.net/license/apply` without being + redirected to `auth.chaitin.net/login`; +- the same profile works after the interactive display is stopped and Chromium + is restarted headlessly; +- `WebLicenseExecutor` can fill an application in dry-run mode with that + profile; +- no VNC port is exposed publicly and no credential is copied into the repo. + +## Architecture + +The existing Linux server hosts four cooperating pieces: + +1. `Xvfb` supplies a temporary virtual X11 display without a desktop install. +2. Chromium runs on that display with a persistent user data directory at + `/opt/license-agent/browser-profile`. +3. `x11vnc` exposes that display only on server loopback. The operator reaches + it through an SSH local port forward and performs DingTalk login personally. +4. The agent later launches Chromium headlessly with the same user data + directory. A process lock ensures only one login check, application, or + download owns the profile at a time. + +VNC and Xvfb are maintenance tools, not permanent public services. The VNC +listener must bind to `127.0.0.1`; the cloud firewall must not open its port. + +## Runtime configuration + +Add `RELEASE_USER_DATA_DIR` as the preferred production authentication source. +When it is set, browser contexts use Playwright's persistent-context API. +`RELEASE_STORAGE_STATE` remains supported for local development and migration, +but configuring both is an error so operators cannot silently use the wrong +session source. + +All Release browser users share a helper responsible for Chromium launch, +profile locking, and cleanup. The executor, downloader, and live-check command +must use this helper so validation exercises the same path as production. + +The profile directory is owned by the `license-agent` service account with mode +`0700`. It is never archived, uploaded, logged, or committed. The lock lives in +the runtime data directory rather than inside the Chromium profile. + +## Interactive login flow + +A server-side maintenance script performs these steps: + +1. Stop the agent service to prevent concurrent profile use. +2. Verify the VNC listener is loopback-only and start Xvfb. +3. Start Chromium as `license-agent` with the persistent profile and Release + application URL. +4. Start password-protected `x11vnc` on loopback and print the exact SSH tunnel + command for the operator. +5. The operator connects from the Mac and completes DingTalk login. +6. After operator confirmation, stop Chromium cleanly so profile data flushes. +7. Run the headless live check against the same profile. +8. Stop VNC/Xvfb and restart the agent only when validation passes. + +The script must fail closed: failed validation leaves the agent stopped and +prints recovery commands. It must not overwrite or delete an existing profile +automatically. + +## Error handling + +Login validation is stronger than checking cookies or the final URL alone. It +must require all of the following: + +- final URL is under `release.chaitin.net` and is not a login URL; +- the expected application page marker is present; +- the page does not contain an authentication error marker. + +Diagnostics include final URL, title, validation reason, and an optional +screenshot path. Cookie values, local storage values, and credentials are never +printed. + +Profile lock contention returns a clear operational error instead of starting a +second Chromium process. Browser crashes release the process lock through normal +context cleanup; stale filesystem artifacts are not treated as proof that a +process still owns the profile. + +## Deployment and compatibility + +The installer adds Xvfb, x11vnc, and the Chromium runtime dependencies supported +by the detected Debian or Ubuntu release. Interactive login tooling is installed +separately from the always-on agent service and does not expose a listening port +after maintenance ends. + +Existing `storage_state` deployments continue to run. Migration consists of +creating the server profile, validating it headlessly, setting +`RELEASE_USER_DATA_DIR`, removing `RELEASE_STORAGE_STATE` from the service +environment, and running a dry-run application before enabling real submission. + +## Verification + +Automated tests cover authentication-source selection, conflicting settings, +profile lock behavior, persistent-context launch arguments, and page-marker +validation. Existing executor and downloader tests must remain green. + +Server acceptance is performed in this order: + +1. interactive login reaches the Release application page; +2. Chromium is fully stopped; +3. a fresh headless process validates the persistent profile; +4. the agent performs a dry-run application and writes a preview screenshot; +5. the service restarts and a second live check still passes. + +Real submission remains disabled until the dry-run screenshot is reviewed. diff --git a/license_agent/browser_session.py b/license_agent/browser_session.py new file mode 100644 index 0000000..11b7d56 --- /dev/null +++ b/license_agent/browser_session.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import fcntl +import hashlib +from contextlib import asynccontextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Any, AsyncIterator + + +class ProfileInUseError(RuntimeError): + pass + + +@dataclass(frozen=True) +class BrowserAuthConfig: + storage_state_path: Path | None = None + user_data_dir: Path | None = None + + @classmethod + def from_values( + cls, + storage_state_path: str | Path | None, + user_data_dir: str | Path | None, + ) -> "BrowserAuthConfig": + if storage_state_path and user_data_dir: + raise ValueError( + "RELEASE_STORAGE_STATE and RELEASE_USER_DATA_DIR cannot both be set" + ) + return cls( + storage_state_path=( + Path(storage_state_path) if storage_state_path else None + ), + user_data_dir=Path(user_data_dir) if user_data_dir else None, + ) + + +class BrowserSession: + def __init__( + self, + playwright: Any, + auth: BrowserAuthConfig, + *, + headless: bool = True, + lock_dir: str | Path = "data", + context_kwargs: dict[str, Any] | None = None, + ) -> None: + self.playwright = playwright + self.auth = auth + self.headless = headless + self.lock_dir = Path(lock_dir) + self.context_kwargs = context_kwargs or {} + + @asynccontextmanager + async def open(self) -> AsyncIterator[tuple[Any, Any | None]]: + lock_file = self._acquire_profile_lock() + try: + if self.auth.user_data_dir: + context = await self.playwright.chromium.launch_persistent_context( + str(self.auth.user_data_dir), + headless=self.headless, + **self.context_kwargs, + ) + try: + yield context, None + finally: + await context.close() + return + + browser = await self.playwright.chromium.launch(headless=self.headless) + context_kwargs = dict(self.context_kwargs) + if self.auth.storage_state_path: + context_kwargs["storage_state"] = str(self.auth.storage_state_path) + context = await browser.new_context(**context_kwargs) + try: + yield context, browser + finally: + await context.close() + await browser.close() + finally: + if lock_file is not None: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + lock_file.close() + + def _acquire_profile_lock(self): + if not self.auth.user_data_dir: + return None + + self.lock_dir.mkdir(parents=True, exist_ok=True) + profile_path = str(self.auth.user_data_dir.expanduser().resolve()) + digest = hashlib.sha256(profile_path.encode("utf-8")).hexdigest()[:12] + lock_path = self.lock_dir / f"release-browser-{digest}.lock" + lock_file = lock_path.open("a+") + try: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as exc: + lock_file.close() + raise ProfileInUseError( + f"Release browser profile is already in use: {self.auth.user_data_dir}" + ) from exc + return lock_file diff --git a/license_agent/conversation.py b/license_agent/conversation.py index d00ac35..738eb3c 100644 --- a/license_agent/conversation.py +++ b/license_agent/conversation.py @@ -1,10 +1,12 @@ from __future__ import annotations +import threading + from .catalog import ProductCatalog, ProductSpec from .download import build_download_targets from .executor import LicenseExecutor from .machine_code import count_machine_codes -from .models import ConversationState, RequestStatus, SubmitResult +from .models import ConversationState, NotificationContext, RequestStatus, SubmitResult from .monitor import PendingDownloadStore from .parser import parse_message @@ -23,6 +25,8 @@ def __init__( self.executor = executor self.pending_downloads = pending_downloads self._states: dict[str, ConversationState] = {} + self._locks: dict[str, threading.RLock] = {} + self._locks_lock = threading.Lock() def handle_message( self, @@ -30,12 +34,32 @@ def handle_message( text: str, applicant: str | None = None, auto_submit: bool = False, + notification_context: NotificationContext | None = None, + ) -> str: + with self._conversation_lock(conversation_id): + return self._handle_message_locked( + conversation_id=conversation_id, + text=text, + applicant=applicant, + auto_submit=auto_submit, + notification_context=notification_context, + ) + + def _handle_message_locked( + self, + conversation_id: str, + text: str, + applicant: str | None = None, + auto_submit: bool = False, + notification_context: NotificationContext | None = None, ) -> str: state = self._states.setdefault( conversation_id, ConversationState(conversation_id=conversation_id), ) parsed = parse_message(text, self.catalog) + if notification_context: + state.notification_context = notification_context state.fields.update({key: value for key, value in parsed.items() if value}) if applicant and "applicant" not in state.fields: state.fields["applicant"] = applicant @@ -85,6 +109,14 @@ def handle_message( trace = f" trace_id={result.trace_id}" if result.trace_id else "" return f"提交失败:{result.message}{trace}" + def _conversation_lock(self, conversation_id: str) -> threading.RLock: + with self._locks_lock: + lock = self._locks.get(conversation_id) + if lock is None: + lock = threading.RLock() + self._locks[conversation_id] = lock + return lock + def _enqueue_downloads( self, state: ConversationState, @@ -102,6 +134,7 @@ def _enqueue_downloads( application_id=application_id, fields=fields, conversation_id=state.conversation_id, + notification_context=state.notification_context, ) self.pending_downloads.add_targets(targets) diff --git a/license_agent/delivery.py b/license_agent/delivery.py index d9dd8ea..50ad29e 100644 --- a/license_agent/delivery.py +++ b/license_agent/delivery.py @@ -1,6 +1,9 @@ from __future__ import annotations +import json import os +import time +import urllib.request from dataclasses import dataclass from pathlib import Path @@ -15,6 +18,48 @@ class DeliveryResult: links: tuple[str, ...] = () +def build_license_links( + *, + public_base_url: str, + file_paths: tuple[str, ...], + download_root: str | Path, + token_secret: str, + ttl_seconds: int, +) -> tuple[str, ...]: + return tuple( + build_file_url( + public_base_url, + file_path, + download_root=download_root, + secret=token_secret, + ttl_seconds=ttl_seconds, + ) + for file_path in file_paths + ) + + +def license_markdown( + *, + title: str, + links: tuple[str, ...], + applicant: str | None, + crm_id: str | None, + machine_code: str | None, + ttl_seconds: int, +) -> str: + lines = [f"### {title}"] + if applicant: + lines.append(f"- 申请人:{applicant}") + if crm_id: + lines.append(f"- CRM ID:{crm_id}") + if machine_code: + lines.append(f"- 机器码:`{machine_code}`") + lines.append(f"- 下载有效期:{ttl_seconds // 3600} 小时") + for index, link in enumerate(links, start=1): + lines.append(f"- [下载授权文件 {index}]({link})") + return "\n".join(lines) + + class DingTalkLinkNotifier: def __init__( self, @@ -61,23 +106,22 @@ def notify_license_files( applicant: str | None = None, crm_id: str | None = None, machine_code: str | None = None, + **_: object, ) -> DeliveryResult: - links = tuple( - build_file_url( - self.public_base_url, - file_path, - download_root=self.download_root, - secret=self.token_secret, - ttl_seconds=self.ttl_seconds, - ) - for file_path in file_paths + links = build_license_links( + public_base_url=self.public_base_url, + file_paths=file_paths, + download_root=self.download_root, + token_secret=self.token_secret, + ttl_seconds=self.ttl_seconds, ) - markdown = self._markdown( + markdown = license_markdown( title=title, links=links, applicant=applicant, crm_id=crm_id, machine_code=machine_code, + ttl_seconds=self.ttl_seconds, ) send_markdown_message( self.webhook_url, @@ -87,23 +131,138 @@ def notify_license_files( ) return DeliveryResult(success=True, message="钉钉通知已发送", links=links) - def _markdown( + +class DingTalkOpenApiNotifier: + def __init__( + self, + *, + client_id: str, + client_secret: str, + corp_id: str, + public_base_url: str, + download_root: str | Path, + token_secret: str, + robot_code: str | None = None, + ttl_seconds: int = 86400, + api_base_url: str = "https://api.dingtalk.com", + ) -> None: + self.client_id = client_id + self.client_secret = client_secret + self.corp_id = corp_id + self.public_base_url = public_base_url + self.download_root = Path(download_root) + self.token_secret = token_secret + self.robot_code = robot_code + self.ttl_seconds = ttl_seconds + self.api_base_url = api_base_url.rstrip("/") + self._access_token: str | None = None + self._access_token_expires_at = 0.0 + + @classmethod + def from_env(cls) -> "DingTalkOpenApiNotifier": + client_id = os.getenv("DINGTALK_OPENAPI_CLIENT_ID") or os.getenv("DINGTALK_STREAM_CLIENT_ID") + client_secret = os.getenv("DINGTALK_OPENAPI_CLIENT_SECRET") or os.getenv("DINGTALK_STREAM_CLIENT_SECRET") + corp_id = os.getenv("DINGTALK_OPENAPI_CORP_ID") + public_base_url = os.getenv("PUBLIC_BASE_URL") + token_secret = os.getenv("DOWNLOAD_TOKEN_SECRET") + if not client_id: + raise RuntimeError("未配置 DINGTALK_OPENAPI_CLIENT_ID 或 DINGTALK_STREAM_CLIENT_ID") + if not client_secret: + raise RuntimeError("未配置 DINGTALK_OPENAPI_CLIENT_SECRET 或 DINGTALK_STREAM_CLIENT_SECRET") + if not corp_id: + raise RuntimeError("未配置 DINGTALK_OPENAPI_CORP_ID") + if not public_base_url: + raise RuntimeError("未配置 PUBLIC_BASE_URL") + if not token_secret: + raise RuntimeError("未配置 DOWNLOAD_TOKEN_SECRET") + return cls( + client_id=client_id, + client_secret=client_secret, + corp_id=corp_id, + public_base_url=public_base_url, + download_root=os.getenv("RELEASE_DOWNLOAD_DIR", "downloads"), + token_secret=token_secret, + robot_code=os.getenv("DINGTALK_OPENAPI_ROBOT_CODE"), + ttl_seconds=int(os.getenv("DOWNLOAD_LINK_TTL_SECONDS", "86400")), + api_base_url=os.getenv("DINGTALK_OPENAPI_BASE_URL", "https://api.dingtalk.com"), + ) + + def notify_license_files( self, *, - title: str, - links: tuple[str, ...], - applicant: str | None, - crm_id: str | None, - machine_code: str | None, - ) -> str: - lines = [f"### {title}"] - if applicant: - lines.append(f"- 申请人:{applicant}") - if crm_id: - lines.append(f"- CRM ID:{crm_id}") - if machine_code: - lines.append(f"- 机器码:`{machine_code}`") - lines.append(f"- 下载有效期:{self.ttl_seconds // 3600} 小时") - for index, link in enumerate(links, start=1): - lines.append(f"- [下载授权文件 {index}]({link})") - return "\n".join(lines) + file_paths: tuple[str, ...], + title: str = "授权文件已生成", + applicant: str | None = None, + crm_id: str | None = None, + machine_code: str | None = None, + dingtalk_user_id: str | None = None, + dingtalk_robot_code: str | None = None, + **_: object, + ) -> DeliveryResult: + user_id = dingtalk_user_id + robot_code = dingtalk_robot_code or self.robot_code + if not user_id: + raise RuntimeError("OpenAPI 通知缺少 dingtalk_user_id") + if not robot_code: + raise RuntimeError("OpenAPI 通知缺少 dingtalk_robot_code") + links = build_license_links( + public_base_url=self.public_base_url, + file_paths=file_paths, + download_root=self.download_root, + token_secret=self.token_secret, + ttl_seconds=self.ttl_seconds, + ) + markdown = license_markdown( + title=title, + links=links, + applicant=applicant, + crm_id=crm_id, + machine_code=machine_code, + ttl_seconds=self.ttl_seconds, + ) + _post_json( + f"{self.api_base_url}/v1.0/robot/oToMessages/batchSend", + { + "robotCode": robot_code, + "userIds": [user_id], + "msgKey": "sampleMarkdown", + "msgParam": json.dumps({"title": title, "text": markdown}, ensure_ascii=False), + }, + headers={"x-acs-dingtalk-access-token": self._access_token_value()}, + ) + return DeliveryResult(success=True, message="钉钉 OpenAPI 通知已发送", links=links) + + def _access_token_value(self) -> str: + now = time.time() + if self._access_token and now < self._access_token_expires_at - 60: + return self._access_token + response = _post_json( + f"{self.api_base_url}/v1.0/oauth2/{self.corp_id}/token", + { + "client_id": self.client_id, + "client_secret": self.client_secret, + "grant_type": "client_credentials", + }, + ) + token = response.get("access_token") or response.get("accessToken") + if not token: + raise RuntimeError(f"钉钉 OpenAPI 未返回 access_token:{response}") + expires_in = int(response.get("expires_in") or response.get("expireIn") or 7200) + self._access_token = str(token) + self._access_token_expires_at = now + expires_in + return self._access_token + + +def _post_json(url: str, payload: dict, *, headers: dict[str, str] | None = None) -> dict: + request_headers = {"Content-Type": "application/json"} + if headers: + request_headers.update(headers) + request = urllib.request.Request( + url, + data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), + headers=request_headers, + method="POST", + ) + with urllib.request.urlopen(request, timeout=10) as response: + body = response.read().decode("utf-8") + return json.loads(body) if body else {} diff --git a/license_agent/dingtalk.py b/license_agent/dingtalk.py index 0f044fe..023fd57 100644 --- a/license_agent/dingtalk.py +++ b/license_agent/dingtalk.py @@ -4,6 +4,7 @@ import hashlib import hmac import json +import re import time import urllib.request import urllib.parse @@ -20,6 +21,15 @@ def current_timestamp_ms() -> int: return int(time.time() * 1000) +def is_submit_command(text: str) -> bool: + normalized = re.sub(r"[\s ]+", "", text) + if normalized == "提交": + return True + if normalized.endswith("提交") and "=" not in normalized and "=" not in text: + return True + return False + + def text_reply(text: str) -> dict: return { "msgtype": "text", diff --git a/license_agent/download.py b/license_agent/download.py index 2e71c24..e824b7f 100644 --- a/license_agent/download.py +++ b/license_agent/download.py @@ -7,9 +7,11 @@ from dataclasses import dataclass from pathlib import Path +from .models import NotificationContext from .login_state import is_login_url, login_required_message from .machine_code import split_machine_codes from .runtime import run_coro_sync +from .browser_session import BrowserAuthConfig, BrowserSession @dataclass(frozen=True) @@ -20,6 +22,12 @@ class LicenseDownloadTarget: crm_id: str | None = None conversation_id: str | None = None applicant: str | None = None + notify_channel: str | None = None + dingtalk_user_id: str | None = None + dingtalk_robot_code: str | None = None + dingtalk_conversation_id: str | None = None + dingtalk_conversation_type: str | None = None + dingtalk_corp_id: str | None = None @dataclass(frozen=True) @@ -52,12 +60,16 @@ def __init__( *, headless: bool = True, storage_state_path: str | None = None, + user_data_dir: str | Path | None = None, + lock_dir: str | Path = "data", download_dir: str | Path = "downloads", base_url: str = "https://release.chaitin.net/license", download_timeout_ms: int = 30000, ) -> None: self.headless = headless + self.auth = BrowserAuthConfig.from_values(storage_state_path, user_data_dir) self.storage_state_path = storage_state_path + self.lock_dir = Path(lock_dir) self.download_dir = Path(download_dir) self.base_url = base_url.rstrip("/") self.download_timeout_ms = download_timeout_ms @@ -67,6 +79,8 @@ def from_env(cls) -> "WebLicenseDownloader": return cls( headless=os.getenv("RELEASE_HEADLESS", "true").lower() != "false", storage_state_path=os.getenv("RELEASE_STORAGE_STATE"), + user_data_dir=os.getenv("RELEASE_USER_DATA_DIR"), + lock_dir=os.getenv("RELEASE_DATA_DIR", "data"), download_dir=os.getenv("RELEASE_DOWNLOAD_DIR", "downloads"), base_url=os.getenv("RELEASE_LICENSE_BASE_URL", "https://release.chaitin.net/license"), download_timeout_ms=int(os.getenv("RELEASE_DOWNLOAD_TIMEOUT_MS", "30000")), @@ -97,19 +111,23 @@ async def _download_async( output_dir.mkdir(parents=True, exist_ok=True) async with async_playwright() as playwright: - browser = await playwright.chromium.launch(headless=self.headless) - context_kwargs = {"accept_downloads": True} - if self.storage_state_path: - context_kwargs["storage_state"] = self.storage_state_path - context = await browser.new_context(**context_kwargs) - page = await context.new_page() - try: + session = BrowserSession( + playwright, + self.auth, + headless=self.headless, + lock_dir=self.lock_dir, + context_kwargs={"accept_downloads": True}, + ) + async with session.open() as (context, _browser): + page = await context.new_page() await page.goto(_detail_url_for(target, self.base_url)) await page.wait_for_load_state("domcontentloaded") if is_login_url(page.url): raise RuntimeError( login_required_message( - self.storage_state_path or "config/release-storage-state.json" + self.auth.user_data_dir + or self.auth.storage_state_path + or "config/release-storage-state.json" ) ) @@ -140,9 +158,6 @@ async def _download_async( file_paths=(str(file_path),), trace_id=trace_id, ) - finally: - await context.close() - await browser.close() def build_download_targets( @@ -150,6 +165,7 @@ def build_download_targets( application_id: str, fields: dict[str, str], conversation_id: str | None = None, + notification_context: NotificationContext | None = None, ) -> tuple[LicenseDownloadTarget, ...]: machine_codes = split_machine_codes(fields.get("machine_code", "")) if not machine_codes: @@ -163,6 +179,16 @@ def build_download_targets( crm_id=fields.get("crm_id"), conversation_id=conversation_id, applicant=fields.get("applicant"), + notify_channel=notification_context.notify_channel if notification_context else None, + dingtalk_user_id=notification_context.dingtalk_user_id if notification_context else None, + dingtalk_robot_code=notification_context.dingtalk_robot_code if notification_context else None, + dingtalk_conversation_id=( + notification_context.dingtalk_conversation_id if notification_context else None + ), + dingtalk_conversation_type=( + notification_context.dingtalk_conversation_type if notification_context else None + ), + dingtalk_corp_id=notification_context.dingtalk_corp_id if notification_context else None, ) for machine_code in machine_codes ) diff --git a/license_agent/executor.py b/license_agent/executor.py index e963b36..a785b53 100644 --- a/license_agent/executor.py +++ b/license_agent/executor.py @@ -11,6 +11,7 @@ from .release_page import prepare_leichi_20_application, save_preview_screenshot from .runtime import run_coro_sync from .login_state import is_login_url, login_required_message +from .browser_session import BrowserAuthConfig, BrowserSession class LicenseExecutor(ABC): @@ -50,12 +51,16 @@ def __init__( headless: bool = True, dry_run: bool = True, storage_state_path: str | None = None, + user_data_dir: str | Path | None = None, + lock_dir: str | Path = "data", screenshot_dir: str | Path = "traces", base_url: str = "https://release.chaitin.net/license/apply", ) -> None: self.headless = headless self.dry_run = dry_run + self.auth = BrowserAuthConfig.from_values(storage_state_path, user_data_dir) self.storage_state_path = storage_state_path + self.lock_dir = Path(lock_dir) self.screenshot_dir = Path(screenshot_dir) self.base_url = base_url @@ -65,6 +70,8 @@ def from_env(cls) -> "WebLicenseExecutor": headless=os.getenv("RELEASE_HEADLESS", "true").lower() != "false", dry_run=os.getenv("RELEASE_DRY_RUN", "true").lower() != "false", storage_state_path=os.getenv("RELEASE_STORAGE_STATE"), + user_data_dir=os.getenv("RELEASE_USER_DATA_DIR"), + lock_dir=os.getenv("RELEASE_DATA_DIR", "data"), screenshot_dir=os.getenv("RELEASE_SCREENSHOT_DIR", "traces"), base_url=os.getenv( "AUTH_WEB_BASE_URL", @@ -91,19 +98,22 @@ async def _submit_async(self, fields: dict[str, str]) -> SubmitResult: trace_id = f"WEB-{uuid.uuid4().hex[:8]}" screenshot_path = self.screenshot_dir / f"{trace_id}.png" async with async_playwright() as playwright: - browser = await playwright.chromium.launch(headless=self.headless) - context_kwargs = {} - if self.storage_state_path: - context_kwargs["storage_state"] = self.storage_state_path - context = await browser.new_context(**context_kwargs) - page = await context.new_page() - try: + session = BrowserSession( + playwright, + self.auth, + headless=self.headless, + lock_dir=self.lock_dir, + ) + async with session.open() as (context, _browser): + page = await context.new_page() await page.goto(self.base_url) await page.wait_for_load_state("domcontentloaded") if is_login_url(page.url): raise RuntimeError( login_required_message( - self.storage_state_path or "config/release-storage-state.json" + self.auth.user_data_dir + or self.auth.storage_state_path + or "config/release-storage-state.json" ) ) @@ -128,18 +138,19 @@ async def _submit_async(self, fields: dict[str, str]) -> SubmitResult: await page.get_by_role("button", name="提交").click() await page.wait_for_load_state("networkidle") result_path = await save_preview_screenshot(page, screenshot_path) - detail_url = page.url + detail_url = _detail_url_or_none(page.url) return SubmitResult( success=True, message="已提交授权申请,请在 Release 平台确认审批状态", - application_id=_record_id_from_detail_url(detail_url), + application_id=_record_id_from_detail_url(detail_url or ""), detail_url=detail_url, trace_id=trace_id, preview_path=result_path, ) - finally: - await context.close() - await browser.close() + + +def _detail_url_or_none(url: str) -> str | None: + return url if _record_id_from_detail_url(url) else None def _record_id_from_detail_url(url: str) -> str | None: diff --git a/license_agent/login_state.py b/license_agent/login_state.py index f5366e1..3381191 100644 --- a/license_agent/login_state.py +++ b/license_agent/login_state.py @@ -5,6 +5,9 @@ from pathlib import Path from typing import Any import json +from urllib.parse import urlparse + +from .browser_session import BrowserAuthConfig, BrowserSession @dataclass(frozen=True) @@ -89,35 +92,59 @@ def login_required_message(storage_path: str | Path) -> str: ) +def validate_application_page(url: str, body_text: str) -> tuple[bool, str]: + if is_login_url(url): + return False, "访问 Release 时被重定向到登录页" + if urlparse(url).hostname != "release.chaitin.net": + return False, "最终页面不属于 release.chaitin.net" + if "认证失败" in body_text or "未认证" in body_text: + return False, "Release 页面显示认证错误" + application_markers = ("License信息(自建)", "产品类型", "提交") + if any(marker not in body_text for marker in application_markers): + return False, "Release 页面未找到授权申请表单标记" + return True, "" + + async def check_live_login_state( - storage_path: str | Path, + auth: BrowserAuthConfig, url: str, *, headless: bool = True, + lock_dir: str | Path = "data", ) -> LiveLoginCheck: try: + from playwright.async_api import TimeoutError as PlaywrightTimeoutError from playwright.async_api import async_playwright except ImportError as exc: raise RuntimeError("未安装 Playwright,请先执行:pip install playwright") from exc async with async_playwright() as playwright: - browser = await playwright.chromium.launch(headless=headless) - context = await browser.new_context(storage_state=str(storage_path)) - page = await context.new_page() - try: + session = BrowserSession( + playwright, + auth, + headless=headless, + lock_dir=lock_dir, + ) + async with session.open() as (context, _browser): + page = await context.new_page() await page.goto(url) await page.wait_for_load_state("domcontentloaded") + try: + await page.locator("body").filter( + has_text="License信息(自建)" + ).wait_for(timeout=10_000) + except PlaywrightTimeoutError: + pass final_url = page.url title = await page.title() - if is_login_url(final_url): + body_text = await page.locator("body").inner_text() + valid, reason = validate_application_page(final_url, body_text) + if not valid: return LiveLoginCheck( valid=False, url=url, final_url=final_url, title=title, - reason="访问 Release 时被重定向到登录页", + reason=reason, ) return LiveLoginCheck(valid=True, url=url, final_url=final_url, title=title) - finally: - await context.close() - await browser.close() diff --git a/license_agent/main.py b/license_agent/main.py index 68b02f7..ca36a7d 100644 --- a/license_agent/main.py +++ b/license_agent/main.py @@ -9,10 +9,11 @@ from .catalog import ProductCatalog from .conversation import ConversationManager -from .dingtalk import text_reply +from .dingtalk import is_submit_command, text_reply from .executor import MockLicenseExecutor, WebLicenseExecutor from .file_links import resolve_file_token from .monitor import DownloadMonitor, PendingDownloadStore +from .stream import DingTalkStreamRunner, StreamMessage ROOT = Path(__file__).resolve().parents[1] @@ -32,6 +33,15 @@ manager = ConversationManager(catalog, executor, pending_downloads=pending_downloads) app = FastAPI(title="钉钉授权申请 Agent") download_monitor_task: asyncio.Task | None = None +stream_runner: DingTalkStreamRunner | None = None + + +@app.on_event("startup") +async def start_stream_runner() -> None: + global stream_runner + stream_runner = DingTalkStreamRunner.from_env(lambda: handle_dingtalk_stream_message) + if stream_runner: + stream_runner.start() @app.on_event("startup") @@ -44,6 +54,12 @@ async def start_download_monitor() -> None: download_monitor_task = asyncio.create_task(monitor.run_forever(interval_seconds)) +@app.on_event("shutdown") +async def stop_stream_runner() -> None: + if stream_runner: + stream_runner.stop() + + @app.on_event("shutdown") async def stop_download_monitor() -> None: if download_monitor_task: @@ -88,15 +104,38 @@ async def dingtalk_webhook(request: Request) -> dict: or "default" ) applicant = payload.get("senderNick") or payload.get("senderStaffId") - auto_submit = text.strip() == "提交" reply = await asyncio.to_thread( - manager.handle_message, + handle_dingtalk_text, + conversation_id, + text, + applicant, + is_submit_command(text), + ) + return text_reply(reply) + + +def handle_dingtalk_text( + conversation_id: str, + text: str, + applicant: str | None = None, + auto_submit: bool | None = None, +) -> str: + return manager.handle_message( conversation_id=conversation_id, text=text, applicant=applicant, + auto_submit=is_submit_command(text) if auto_submit is None else auto_submit, + ) + + +def handle_dingtalk_stream_message(message: StreamMessage, auto_submit: bool) -> str: + return manager.handle_message( + conversation_id=message.conversation_id, + text=message.text, + applicant=message.applicant, auto_submit=auto_submit, + notification_context=message.notification_context(), ) - return text_reply(reply) def _extract_text(payload: dict) -> str: diff --git a/license_agent/models.py b/license_agent/models.py index 84263f7..30ddb11 100644 --- a/license_agent/models.py +++ b/license_agent/models.py @@ -5,6 +5,16 @@ from enum import Enum +@dataclass(frozen=True) +class NotificationContext: + notify_channel: str | None = None + dingtalk_user_id: str | None = None + dingtalk_robot_code: str | None = None + dingtalk_conversation_id: str | None = None + dingtalk_conversation_type: str | None = None + dingtalk_corp_id: str | None = None + + class RequestStatus(str, Enum): COLLECTING = "collecting" READY_TO_SUBMIT = "ready_to_submit" @@ -17,6 +27,7 @@ class RequestStatus(str, Enum): class ConversationState: conversation_id: str fields: dict[str, str] = field(default_factory=dict) + notification_context: NotificationContext | None = None status: RequestStatus = RequestStatus.COLLECTING updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/license_agent/monitor.py b/license_agent/monitor.py index a36af68..d41789b 100644 --- a/license_agent/monitor.py +++ b/license_agent/monitor.py @@ -2,19 +2,22 @@ import asyncio import json +import logging import os import uuid from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from pathlib import Path +from typing import Protocol -from .delivery import DingTalkLinkNotifier +from .delivery import DeliveryResult, DingTalkLinkNotifier, DingTalkOpenApiNotifier from .download import LicenseDownloadTarget, LicenseDownloader, WebLicenseDownloader WAITING_STATUS = "waiting" DONE_STATUS = "done" FAILED_STATUS = "failed" +logger = logging.getLogger(__name__) def _utc_now() -> str: @@ -30,6 +33,12 @@ class PendingDownloadTask: crm_id: str | None = None conversation_id: str | None = None applicant: str | None = None + notify_channel: str | None = None + dingtalk_user_id: str | None = None + dingtalk_robot_code: str | None = None + dingtalk_conversation_id: str | None = None + dingtalk_conversation_type: str | None = None + dingtalk_corp_id: str | None = None status: str = WAITING_STATUS attempts: int = 0 last_error: str | None = None @@ -45,6 +54,12 @@ def to_target(self) -> LicenseDownloadTarget: crm_id=self.crm_id, conversation_id=self.conversation_id, applicant=self.applicant, + notify_channel=self.notify_channel, + dingtalk_user_id=self.dingtalk_user_id, + dingtalk_robot_code=self.dingtalk_robot_code, + dingtalk_conversation_id=self.dingtalk_conversation_id, + dingtalk_conversation_type=self.dingtalk_conversation_type, + dingtalk_corp_id=self.dingtalk_corp_id, ) @@ -72,6 +87,12 @@ def add_targets(self, targets: tuple[LicenseDownloadTarget, ...]) -> tuple[Pendi crm_id=target.crm_id, conversation_id=target.conversation_id, applicant=target.applicant, + notify_channel=target.notify_channel, + dingtalk_user_id=target.dingtalk_user_id, + dingtalk_robot_code=target.dingtalk_robot_code, + dingtalk_conversation_id=target.dingtalk_conversation_id, + dingtalk_conversation_type=target.dingtalk_conversation_type, + dingtalk_corp_id=target.dingtalk_corp_id, ) for target in targets ) @@ -151,13 +172,31 @@ def _save(self, tasks: list[PendingDownloadTask]) -> None: json.dump(payload, file, ensure_ascii=False, indent=2) +class LicenseFileNotifier(Protocol): + def notify_license_files( + self, + *, + file_paths: tuple[str, ...], + title: str = "授权文件已生成", + applicant: str | None = None, + crm_id: str | None = None, + machine_code: str | None = None, + dingtalk_user_id: str | None = None, + dingtalk_robot_code: str | None = None, + dingtalk_conversation_id: str | None = None, + dingtalk_conversation_type: str | None = None, + dingtalk_corp_id: str | None = None, + ) -> DeliveryResult | None: + ... + + class DownloadMonitor: def __init__( self, *, store: PendingDownloadStore, downloader: LicenseDownloader, - notifier: DingTalkLinkNotifier | None = None, + notifier: LicenseFileNotifier | None = None, max_attempts: int = 0, ) -> None: self.store = store @@ -169,7 +208,11 @@ def __init__( def from_env(cls) -> "DownloadMonitor": notifier = None if os.getenv("LICENSE_DOWNLOAD_NOTIFY", "false").lower() == "true": - notifier = DingTalkLinkNotifier.from_env() + notify_mode = os.getenv("DINGTALK_NOTIFY_MODE", "webhook").lower() + if notify_mode == "openapi": + notifier = DingTalkOpenApiNotifier.from_env() + else: + notifier = DingTalkLinkNotifier.from_env() return cls( store=PendingDownloadStore.from_env(), downloader=WebLicenseDownloader.from_env(), @@ -186,12 +229,20 @@ def poll_once(self) -> dict[str, int]: self.store.mark_done(task.task_id, result.file_paths) stats["downloaded"] += 1 if self.notifier: - self.notifier.notify_license_files( - file_paths=result.file_paths, - applicant=task.applicant, - crm_id=task.crm_id, - machine_code=task.machine_code, - ) + try: + self.notifier.notify_license_files( + file_paths=result.file_paths, + applicant=task.applicant, + crm_id=task.crm_id, + machine_code=task.machine_code, + dingtalk_user_id=task.dingtalk_user_id, + dingtalk_robot_code=task.dingtalk_robot_code, + dingtalk_conversation_id=task.dingtalk_conversation_id, + dingtalk_conversation_type=task.dingtalk_conversation_type, + dingtalk_corp_id=task.dingtalk_corp_id, + ) + except Exception: + logger.exception("授权文件已下载,但钉钉通知发送失败:task_id=%s", task.task_id) continue if self.max_attempts and task.attempts + 1 >= self.max_attempts: diff --git a/license_agent/parser.py b/license_agent/parser.py index c8258c9..8e70467 100644 --- a/license_agent/parser.py +++ b/license_agent/parser.py @@ -5,15 +5,18 @@ from .catalog import ProductCatalog -KEY_PATTERN = ( - r"(?:[A-Za-z]+(?:\s+[A-Za-z]+)+)" - r"|(?:[\u4e00-\u9fff]+(?:\s+[A-Za-z]+)+)" - r"|(?:[\w\u4e00-\u9fff]+)" -) -PAIR_RE = re.compile( - rf"(?P{KEY_PATTERN})\s*[::=]\s*" - rf"(?P.*?)(?=(?:\s+|[,,;;]\s*)(?:{KEY_PATTERN})\s*[::=]|[\n\r]|$)" -) +FIELD_SEPARATOR = r"\s*[::=]\s*" + + +def _pair_pattern(catalog: ProductCatalog) -> re.Pattern[str]: + aliases = sorted(catalog.alias_to_field, key=len, reverse=True) + known_key = "|".join(re.escape(alias) for alias in aliases) + return re.compile( + rf"(?{known_key}){FIELD_SEPARATOR}" + rf"(?P.*?)(?=(?:\s+|[,,;;]\s*)" + rf"(?:{known_key}){FIELD_SEPARATOR}|[\n\r]|$)", + re.IGNORECASE, + ) def normalize_duration(value: str) -> str: @@ -26,7 +29,7 @@ def normalize_duration(value: str) -> str: def parse_message(text: str, catalog: ProductCatalog) -> dict[str, str]: fields: dict[str, str] = {} - for match in PAIR_RE.finditer(text): + for match in _pair_pattern(catalog).finditer(text): raw_key = match.group("key").strip().casefold() field = catalog.alias_to_field.get(raw_key) if not field: diff --git a/license_agent/release_page.py b/license_agent/release_page.py index c508d80..73812cd 100644 --- a/license_agent/release_page.py +++ b/license_agent/release_page.py @@ -16,6 +16,27 @@ ) +def normalize_option_label(value: str) -> str: + return "".join(value.split()).casefold() + + +def resolve_option_index(labels: Sequence[str], target: str) -> int: + normalized_target = normalize_option_label(target) + matches = [ + index + for index, label in enumerate(labels) + if normalize_option_label(label) == normalized_target + ] + if not matches: + available = "、".join(label.strip() for label in labels if label.strip()) + raise RuntimeError( + f"未找到下拉选项:{target};当前可见选项:{available or '无'}" + ) + if len(matches) > 1: + raise RuntimeError(f"匹配到多个下拉选项:{target}") + return matches[0] + + async def get_feature_states(page, feature_names: Sequence[str] = FEATURE_NAMES) -> list[dict]: return await page.evaluate( """ @@ -119,35 +140,34 @@ async def prepare_leichi_20_application( await page.wait_for_load_state("domcontentloaded") await select_md_option(page, 0, "雷池 20 系列") - await select_md_option(page, 2, fields.get("license_purpose", "POC")) - await select_md_option(page, 4, fields.get("usage_scenario", "新签")) - await set_radio_by_input_index(page, 7) + await select_md_option(page, 1, fields.get("license_purpose", "POC")) + await select_md_option(page, 2, fields.get("usage_scenario", "新签")) + await set_radio_by_input_index(page, 8) await select_crm_project(page, fields["crm_id"]) - await set_radio_by_input_index(page, 17) - await fill_input_by_index(page, 19, fields.get("device_count", "1")) - await set_radio_by_input_index(page, 23) - await set_radio_by_input_index(page, 25) + await set_radio_by_input_index(page, 18) + await fill_input_by_index(page, 20, fields.get("device_count", "1")) + await set_radio_by_input_index(page, 24) + await set_radio_by_input_index(page, 26) await ensure_all_features_selected(page) return await collect_preview_state(page) -async def select_md_option(page, input_index: int, option_text: str) -> None: - current_value = await page.locator("input").nth(input_index).input_value() - if current_value == option_text: +async def select_md_option(page, select_index: int, option_text: str) -> None: + select_input = page.locator("input.md-select-value").nth(select_index) + current_value = await select_input.input_value() + if normalize_option_label(current_value) == normalize_option_label(option_text): return - await page.locator("input").nth(input_index).click() + await select_input.click() await page.wait_for_timeout(300) - options = page.locator(".md-select-menu .md-list-item-button").filter( - has_text=option_text - ) - if await options.count() == 0: - raise RuntimeError(f"未找到下拉选项:{option_text}") - await options.nth(0).click() + options = page.locator(".md-list-item-button:visible") + labels = await options.all_inner_texts() + option_index = resolve_option_index(labels, option_text) + await options.nth(option_index).click() await page.wait_for_timeout(500) async def select_crm_project(page, crm_id: str) -> None: - crm_input = page.locator("input").nth(8) + crm_input = page.locator("input").nth(9) await crm_input.fill(crm_id) await crm_input.click() await crm_input.press("Enter") @@ -162,7 +182,7 @@ async def select_crm_project(page, crm_id: str) -> None: async def set_radio_by_input_index(page, input_index: int) -> None: radio = page.locator("input").nth(input_index) if not await radio.is_checked(): - await radio.check() + await radio.check(force=True) await page.wait_for_timeout(300) @@ -201,8 +221,8 @@ async def collect_preview_state(page) -> dict: crmProjectVisible: bodyText.includes('项目 CRMID'), customerName: inputs.find((item) => item.placeholder.includes('测试 License 申请'))?.value || '', deviceCount: inputs.find((item) => item.type === 'number')?.value || '', - limitNodesNo: Boolean(inputs[23]?.checked), - limitQpsNo: Boolean(inputs[25]?.checked), + limitNodesNo: Boolean(inputs[24]?.checked), + limitQpsNo: Boolean(inputs[26]?.checked), }; } """ diff --git a/license_agent/stream.py b/license_agent/stream.py new file mode 100644 index 0000000..56bbc4a --- /dev/null +++ b/license_agent/stream.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +import os +import threading +from dataclasses import dataclass +from typing import Callable + +import dingtalk_stream + +from .dingtalk import is_submit_command +from .models import NotificationContext + + +@dataclass(frozen=True) +class DingTalkStreamConfig: + enabled: bool + client_id: str + client_secret: str + reply_timeout_seconds: float = 3.0 + async_submit: bool = True + + @classmethod + def from_env(cls) -> "DingTalkStreamConfig": + enabled = _env_bool("DINGTALK_STREAM_ENABLED", False) + client_id = os.getenv("DINGTALK_STREAM_CLIENT_ID", "").strip() + client_secret = os.getenv("DINGTALK_STREAM_CLIENT_SECRET", "").strip() + if enabled and (not client_id or not client_secret): + raise RuntimeError("启用钉钉 Stream 时必须配置 DINGTALK_STREAM_CLIENT_ID 和 DINGTALK_STREAM_CLIENT_SECRET") + return cls( + enabled=enabled, + client_id=client_id, + client_secret=client_secret, + reply_timeout_seconds=float(os.getenv("DINGTALK_STREAM_REPLY_TIMEOUT_SECONDS", "3")), + async_submit=_env_bool("DINGTALK_STREAM_ASYNC_SUBMIT", True), + ) + + +@dataclass(frozen=True) +class StreamMessage: + text: str + conversation_id: str + applicant: str | None = None + msg_id: str | None = None + sender_staff_id: str | None = None + sender_id: str | None = None + sender_nick: str | None = None + robot_code: str | None = None + conversation_type: str | None = None + chatbot_corp_id: str | None = None + sender_corp_id: str | None = None + session_webhook_expired_time: str | None = None + + def notification_context(self) -> NotificationContext | None: + if not self.sender_staff_id and not self.robot_code: + return None + return NotificationContext( + notify_channel="dingtalk_openapi", + dingtalk_user_id=self.sender_staff_id, + dingtalk_robot_code=self.robot_code, + dingtalk_conversation_id=self.conversation_id, + dingtalk_conversation_type=self.conversation_type, + dingtalk_corp_id=self.chatbot_corp_id or self.sender_corp_id, + ) + + +class RecentMessageIds: + def __init__(self, max_size: int = 200) -> None: + self.max_size = max_size + self._items: list[str] = [] + self._seen: set[str] = set() + self._lock = threading.Lock() + + def add_if_new(self, msg_id: str | None) -> bool: + if not msg_id: + return True + with self._lock: + if msg_id in self._seen: + return False + self._seen.add(msg_id) + self._items.append(msg_id) + while len(self._items) > self.max_size: + oldest = self._items.pop(0) + self._seen.discard(oldest) + return True + + +def extract_stream_message(raw: object) -> StreamMessage: + text = _extract_text(raw).strip() + conversation_id = ( + _first(raw, "conversation_id", "conversationId") + or _first(raw, "sender_staff_id", "senderStaffId") + or _first(raw, "sender_id", "senderId") + or "default" + ) + applicant = ( + _first(raw, "sender_nick", "senderNick") + or _first(raw, "sender_staff_id", "senderStaffId") + or _first(raw, "sender_id", "senderId") + ) + msg_id = _first(raw, "message_id", "msg_id", "messageId", "msgId") + sender_staff_id = _first(raw, "sender_staff_id", "senderStaffId") + sender_id = _first(raw, "sender_id", "senderId") + sender_nick = _first(raw, "sender_nick", "senderNick") + robot_code = _first(raw, "robot_code", "robotCode") + conversation_type = _first(raw, "conversation_type", "conversationType") + chatbot_corp_id = _first(raw, "chatbot_corp_id", "chatbotCorpId") + sender_corp_id = _first(raw, "sender_corp_id", "senderCorpId") + session_webhook_expired_time = _first( + raw, + "session_webhook_expired_time", + "sessionWebhookExpiredTime", + ) + return StreamMessage( + text=text, + conversation_id=str(conversation_id), + applicant=str(applicant) if applicant else None, + msg_id=str(msg_id) if msg_id else None, + sender_staff_id=str(sender_staff_id) if sender_staff_id else None, + sender_id=str(sender_id) if sender_id else None, + sender_nick=str(sender_nick) if sender_nick else None, + robot_code=str(robot_code) if robot_code else None, + conversation_type=str(conversation_type) if conversation_type else None, + chatbot_corp_id=str(chatbot_corp_id) if chatbot_corp_id else None, + sender_corp_id=str(sender_corp_id) if sender_corp_id else None, + session_webhook_expired_time=( + str(session_webhook_expired_time) if session_webhook_expired_time else None + ), + ) + + +class LicenseChatbotHandler(dingtalk_stream.ChatbotHandler): + def __init__( + self, + *, + reply_handler: Callable[[StreamMessage, bool], str], + config: DingTalkStreamConfig, + ) -> None: + super().__init__() + self.reply_handler = reply_handler + self.config = config + self.recent_ids = RecentMessageIds() + + async def process(self, callback: object) -> tuple[int, str]: + raw_data = getattr(callback, "data", callback) + chatbot_message = self._to_chatbot_message(raw_data) + message = extract_stream_message(chatbot_message) + + if not message.text: + self.logger.info("收到钉钉 Stream 空消息,忽略:conversation_id=%s msg_id=%s", message.conversation_id, message.msg_id) + return dingtalk_stream.AckMessage.STATUS_OK, "OK" + if not self.recent_ids.add_if_new(message.msg_id): + self.logger.info("收到重复钉钉 Stream 消息,忽略:conversation_id=%s msg_id=%s", message.conversation_id, message.msg_id) + return dingtalk_stream.AckMessage.STATUS_OK, "OK" + + self.logger.info( + "收到钉钉 Stream 消息:conversation_id=%s applicant=%s msg_id=%s text_length=%s", + message.conversation_id, + message.applicant, + message.msg_id, + len(message.text), + ) + self.logger.info( + "钉钉 Stream 上下文:robot_code=%s chatbot_corp_id=%s sender_corp_id=%s sender_staff_id=%s conversation_type=%s", + _first(chatbot_message, "robot_code", "robotCode"), + _first(chatbot_message, "chatbot_corp_id", "chatbotCorpId"), + _first(chatbot_message, "sender_corp_id", "senderCorpId"), + _first(chatbot_message, "sender_staff_id", "senderStaffId"), + _first(chatbot_message, "conversation_type", "conversationType"), + ) + auto_submit = is_submit_command(message.text) + if self.config.async_submit and auto_submit: + self._reply_text("已收到提交指令,正在处理,请稍候。", chatbot_message) + threading.Thread( + target=self._process_and_reply, + args=(message, chatbot_message, True), + daemon=True, + ).start() + return dingtalk_stream.AckMessage.STATUS_OK, "OK" + + self._process_and_reply(message, chatbot_message, auto_submit) + return dingtalk_stream.AckMessage.STATUS_OK, "OK" + + @staticmethod + def _to_chatbot_message(raw_data: object) -> object: + if isinstance(raw_data, dict): + return dingtalk_stream.ChatbotMessage.from_dict(raw_data) + return raw_data + + def _process_and_reply(self, message: StreamMessage, chatbot_message: object, auto_submit: bool) -> None: + try: + reply = self.reply_handler(message, auto_submit) + except Exception as exc: # pragma: no cover - defensive path for live callbacks + self.logger.exception("处理钉钉 Stream 消息失败:conversation_id=%s msg_id=%s", message.conversation_id, message.msg_id) + reply = f"处理失败:{exc}" + self._reply_text(reply, chatbot_message) + + def _reply_text(self, text: str, chatbot_message: object) -> None: + self.logger.info("发送钉钉 Stream 回复:text_length=%s", len(text)) + self.reply_text(text, chatbot_message) # type: ignore[arg-type] + + +class DingTalkStreamRunner: + def __init__( + self, + *, + config: DingTalkStreamConfig, + reply_handler_factory: Callable[[], Callable[[StreamMessage, bool], str]], + ) -> None: + self.config = config + self.reply_handler_factory = reply_handler_factory + self.client: dingtalk_stream.DingTalkStreamClient | None = None + self.thread: threading.Thread | None = None + + @classmethod + def from_env( + cls, + reply_handler_factory: Callable[[], Callable[[StreamMessage, bool], str]], + ) -> "DingTalkStreamRunner | None": + config = DingTalkStreamConfig.from_env() + if not config.enabled: + return None + return cls(config=config, reply_handler_factory=reply_handler_factory) + + def start(self) -> None: + if self.thread and self.thread.is_alive(): + return + credential = dingtalk_stream.Credential(self.config.client_id, self.config.client_secret) + self.client = dingtalk_stream.DingTalkStreamClient(credential) + handler = LicenseChatbotHandler( + reply_handler=self.reply_handler_factory(), + config=self.config, + ) + self.client.register_callback_handler(dingtalk_stream.ChatbotMessage.TOPIC, handler) + self.thread = threading.Thread(target=self.client.start_forever, daemon=True) + self.thread.start() + + def stop(self) -> None: + if not self.client: + return + for method_name in ("stop", "close", "disconnect"): + method = getattr(self.client, method_name, None) + if callable(method): + method() + break + + +def _extract_text(raw: object) -> str: + text = _first(raw, "text") + if isinstance(text, dict): + return str(text.get("content") or "") + content = _first(text, "content") if text is not None else None + if content: + return str(content) + if hasattr(raw, "get_text_list"): + values = raw.get_text_list() + if values: + return "\n".join(str(value) for value in values) + return str(_first(raw, "content") or "") + + +def _first(source: object, *names: str) -> object | None: + for name in names: + if source is None: + return None + if isinstance(source, dict) and source.get(name): + return source.get(name) + value = getattr(source, name, None) + if value: + return value + return None + + +def _env_bool(name: str, default: bool) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} diff --git a/pyproject.toml b/pyproject.toml index 8656d05..f39b31c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,8 @@ requires-python = ">=3.10" dependencies = [ "fastapi>=0.110", "uvicorn>=0.27", - "playwright>=1.45" + "playwright>=1.45", + "dingtalk-stream>=0.24" ] [tool.setuptools.packages.find] diff --git a/tests/test_browser_session.py b/tests/test_browser_session.py new file mode 100644 index 0000000..29d01d4 --- /dev/null +++ b/tests/test_browser_session.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from license_agent.browser_session import ( + BrowserAuthConfig, + BrowserSession, + ProfileInUseError, +) + + +class FakeContext: + def __init__(self) -> None: + self.closed = False + + async def close(self) -> None: + self.closed = True + + +class FakeBrowser: + def __init__(self) -> None: + self.context_kwargs: dict[str, object] | None = None + self.context = FakeContext() + self.closed = False + + async def new_context(self, **kwargs): + self.context_kwargs = kwargs + return self.context + + async def close(self) -> None: + self.closed = True + + +class FakeChromium: + def __init__(self) -> None: + self.launch_kwargs: dict[str, object] | None = None + self.persistent_args: tuple[str, dict[str, object]] | None = None + self.browser = FakeBrowser() + self.persistent_context = FakeContext() + + async def launch(self, **kwargs): + self.launch_kwargs = kwargs + return self.browser + + async def launch_persistent_context(self, user_data_dir: str, **kwargs): + self.persistent_args = (user_data_dir, kwargs) + return self.persistent_context + + +class FakePlaywright: + def __init__(self) -> None: + self.chromium = FakeChromium() + + +class BrowserAuthConfigTest(unittest.TestCase): + def test_accepts_each_authentication_mode_or_neither(self) -> None: + storage = BrowserAuthConfig.from_values("state.json", None) + profile = BrowserAuthConfig.from_values(None, "profile") + anonymous = BrowserAuthConfig.from_values(None, None) + + self.assertEqual(storage.storage_state_path, Path("state.json")) + self.assertIsNone(storage.user_data_dir) + self.assertEqual(profile.user_data_dir, Path("profile")) + self.assertIsNone(profile.storage_state_path) + self.assertIsNone(anonymous.storage_state_path) + self.assertIsNone(anonymous.user_data_dir) + + def test_rejects_conflicting_authentication_sources(self) -> None: + with self.assertRaisesRegex(ValueError, "RELEASE_USER_DATA_DIR"): + BrowserAuthConfig.from_values("state.json", "profile") + + +class BrowserSessionTest(unittest.IsolatedAsyncioTestCase): + async def test_opens_persistent_context_and_closes_it(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + profile = Path(temp_dir) / "profile" + playwright = FakePlaywright() + session = BrowserSession( + playwright, + BrowserAuthConfig.from_values(None, profile), + headless=True, + lock_dir=Path(temp_dir) / "locks", + ) + + async with session.open() as (context, browser): + self.assertIs(context, playwright.chromium.persistent_context) + self.assertIsNone(browser) + + self.assertEqual( + playwright.chromium.persistent_args, + (str(profile), {"headless": True}), + ) + self.assertTrue(playwright.chromium.persistent_context.closed) + + async def test_opens_storage_state_context_and_closes_browser(self) -> None: + playwright = FakePlaywright() + session = BrowserSession( + playwright, + BrowserAuthConfig.from_values("state.json", None), + headless=False, + ) + + async with session.open() as (context, browser): + self.assertIs(context, playwright.chromium.browser.context) + self.assertIs(browser, playwright.chromium.browser) + + self.assertEqual(playwright.chromium.launch_kwargs, {"headless": False}) + self.assertEqual( + playwright.chromium.browser.context_kwargs, + {"storage_state": "state.json"}, + ) + self.assertTrue(playwright.chromium.browser.closed) + + async def test_rejects_second_owner_of_same_profile(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + profile = Path(temp_dir) / "profile" + auth = BrowserAuthConfig.from_values(None, profile) + first = BrowserSession( + FakePlaywright(), auth, lock_dir=Path(temp_dir) / "locks" + ) + second = BrowserSession( + FakePlaywright(), auth, lock_dir=Path(temp_dir) / "locks" + ) + + async with first.open(): + with self.assertRaises(ProfileInUseError): + async with second.open(): + self.fail("second profile owner must not start") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_delivery.py b/tests/test_delivery.py new file mode 100644 index 0000000..e89850a --- /dev/null +++ b/tests/test_delivery.py @@ -0,0 +1,69 @@ +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from license_agent.delivery import DingTalkOpenApiNotifier, license_markdown + + +class DeliveryTest(unittest.TestCase): + def test_license_markdown_contains_links(self) -> None: + text = license_markdown( + title="授权文件已生成", + links=("https://agent.example/license-files/token",), + applicant="张三", + crm_id="CRM-123", + machine_code="AAA", + ttl_seconds=86400, + ) + + self.assertIn("授权文件已生成", text) + self.assertIn("CRM-123", text) + self.assertIn("下载授权文件 1", text) + + def test_openapi_notifier_sends_user_robot_message(self) -> None: + calls = [] + + def fake_post_json(url, payload, *, headers=None): + calls.append((url, payload, headers)) + if url.endswith("/token"): + return {"access_token": "token-1", "expires_in": 7200} + return {"processQueryKey": "query-1"} + + with tempfile.TemporaryDirectory() as tmpdir: + file_path = Path(tmpdir) / "license.lic" + file_path.write_text("license", encoding="utf-8") + notifier = DingTalkOpenApiNotifier( + client_id="client-id", + client_secret="client-secret", + corp_id="dingcorp", + public_base_url="https://agent.example", + download_root=tmpdir, + token_secret="secret", + robot_code="fallback-robot", + ) + with patch("license_agent.delivery._post_json", side_effect=fake_post_json): + result = notifier.notify_license_files( + file_paths=(str(file_path),), + applicant="张三", + crm_id="CRM-123", + machine_code="AAA", + dingtalk_user_id="lang.qing", + dingtalk_robot_code="dingbot", + ) + + self.assertTrue(result.success) + self.assertEqual(len(calls), 2) + self.assertTrue(calls[0][0].endswith("/v1.0/oauth2/dingcorp/token")) + self.assertTrue(calls[1][0].endswith("/v1.0/robot/oToMessages/batchSend")) + payload = calls[1][1] + self.assertEqual(payload["robotCode"], "dingbot") + self.assertEqual(payload["userIds"], ["lang.qing"]) + self.assertEqual(payload["msgKey"], "sampleMarkdown") + self.assertIn("CRM-123", json.loads(payload["msgParam"])["text"]) + self.assertEqual(calls[1][2]["x-acs-dingtalk-access-token"], "token-1") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_deploy_scripts.py b/tests/test_deploy_scripts.py new file mode 100644 index 0000000..accac5a --- /dev/null +++ b/tests/test_deploy_scripts.py @@ -0,0 +1,25 @@ +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +class DeployScriptsTest(unittest.TestCase): + def test_vnc_login_is_loopback_only_and_password_protected(self) -> None: + script = (ROOT / "deploy/login_release_vnc.sh").read_text() + self.assertIn('APP_DIR="${APP_DIR:-/AI/lisence-agent}"', script) + self.assertIn("-localhost", script) + self.assertIn("-rfbauth", script) + self.assertIn("chmod 700", script) + self.assertNotIn("-nopw", script) + self.assertNotIn("0.0.0.0", script) + + def test_server_check_supports_persistent_profile(self) -> None: + script = (ROOT / "deploy/check_server.sh").read_text() + self.assertIn("RELEASE_USER_DATA_DIR", script) + self.assertIn("--user-data-dir", script) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_dingtalk.py b/tests/test_dingtalk.py new file mode 100644 index 0000000..e2a904f --- /dev/null +++ b/tests/test_dingtalk.py @@ -0,0 +1,21 @@ +import unittest + +from license_agent.dingtalk import is_submit_command + + +class DingTalkCommandTest(unittest.TestCase): + def test_plain_submit_command(self) -> None: + self.assertTrue(is_submit_command("提交")) + self.assertTrue(is_submit_command(" 提交\n")) + + def test_submit_command_after_at_mention(self) -> None: + self.assertTrue(is_submit_command("@授权机器人 提交")) + self.assertTrue(is_submit_command("@授权机器人 提交")) + + def test_field_message_is_not_submit_command(self) -> None: + self.assertFalse(is_submit_command("备注=请提交审批")) + self.assertFalse(is_submit_command("提交人=张三")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_login_state.py b/tests/test_login_state.py index 4649724..e16d708 100644 --- a/tests/test_login_state.py +++ b/tests/test_login_state.py @@ -4,7 +4,12 @@ from datetime import datetime, timezone from pathlib import Path -from license_agent.login_state import is_login_url, login_required_message, summarize_storage_state +from license_agent.login_state import ( + is_login_url, + login_required_message, + summarize_storage_state, + validate_application_page, +) class LoginStateTest(unittest.TestCase): @@ -48,6 +53,30 @@ def test_login_required_message_includes_recovery_commands(self) -> None: self.assertIn("check_release_login.py", message) self.assertIn("/opt/license-agent/secrets/state.json", message) + def test_validates_real_application_page_markers(self) -> None: + valid, reason = validate_application_page( + "https://release.chaitin.net/license/apply", + "License 管理/ license申请 License信息(自建) 产品类型 提交", + ) + self.assertTrue(valid) + self.assertEqual(reason, "") + + def test_rejects_login_unrelated_and_authentication_error_pages(self) -> None: + cases = ( + ("https://auth.chaitin.net/login", "登录", "登录页"), + ("https://release.chaitin.net/license", "授权列表", "申请表单"), + ( + "https://release.chaitin.net/license/apply", + "认证失败 License信息(自建) 产品类型 提交", + "认证错误", + ), + ) + for url, body, expected_reason in cases: + with self.subTest(url=url, body=body): + valid, reason = validate_application_page(url, body) + self.assertFalse(valid) + self.assertIn(expected_reason, reason) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_monitor.py b/tests/test_monitor.py index b3a4d11..cffddf8 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -10,7 +10,7 @@ LicenseDownloader, ) from license_agent.executor import LicenseExecutor -from license_agent.models import SubmitResult +from license_agent.models import NotificationContext, SubmitResult from license_agent.monitor import DONE_STATUS, WAITING_STATUS, DownloadMonitor, PendingDownloadStore @@ -37,6 +37,11 @@ def download(self, target: LicenseDownloadTarget) -> LicenseDownloadResult: return self.results.pop(0) +class FailingNotifier: + def notify_license_files(self, **_kwargs: object) -> None: + raise RuntimeError("notify failed") + + class MonitorTest(unittest.TestCase): def test_submit_success_enqueues_download_targets(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: @@ -48,6 +53,14 @@ def test_submit_success_enqueues_download_targets(self) -> None: conversation_id="ding-1", text="CRMID=CRM-123 机器码=AAA,BBB", applicant="张三", + notification_context=NotificationContext( + notify_channel="dingtalk_openapi", + dingtalk_user_id="lang.qing", + dingtalk_robot_code="dingbot", + dingtalk_conversation_id="ding-1", + dingtalk_conversation_type="1", + dingtalk_corp_id="dingcorp", + ), ) self.assertIn("信息已完整", reply) @@ -64,6 +77,9 @@ def test_submit_success_enqueues_download_targets(self) -> None: self.assertEqual(tasks[0].detail_url, "https://release.example/license/record-123") self.assertEqual(tasks[0].machine_code, "AAA") self.assertEqual(tasks[1].machine_code, "BBB") + self.assertEqual(tasks[0].notify_channel, "dingtalk_openapi") + self.assertEqual(tasks[0].dingtalk_user_id, "lang.qing") + self.assertEqual(tasks[0].dingtalk_robot_code, "dingbot") def test_monitor_keeps_waiting_until_download_succeeds(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: @@ -102,6 +118,39 @@ def test_monitor_keeps_waiting_until_download_succeeds(self) -> None: self.assertEqual(done_task.task_id, task.task_id) self.assertEqual(done_task.status, DONE_STATUS) self.assertEqual(done_task.file_paths, ("/tmp/license.lic",)) + def test_monitor_marks_done_even_when_notification_fails(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + store = PendingDownloadStore(Path(tmpdir) / "pending.json") + [task] = store.add_targets( + ( + LicenseDownloadTarget( + application_id="record-123", + detail_url="https://release.example/license/record-123", + machine_code="AAA", + crm_id="CRM-123", + ), + ) + ) + downloader = SequenceDownloader( + [ + LicenseDownloadResult( + success=True, + message="授权文件下载成功", + file_paths=("/tmp/license.lic",), + ), + ] + ) + monitor = DownloadMonitor(store=store, downloader=downloader, notifier=FailingNotifier()) + + with self.assertLogs("license_agent.monitor", level="ERROR") as logs: + result = monitor.poll_once() + + self.assertIn("授权文件已下载,但钉钉通知发送失败", logs.output[0]) + self.assertEqual(result["downloaded"], 1) + done_task = store.list_all()[0] + self.assertEqual(done_task.task_id, task.task_id) + self.assertEqual(done_task.status, DONE_STATUS) + self.assertEqual(done_task.file_paths, ("/tmp/license.lic",)) if __name__ == "__main__": diff --git a/tests/test_parser.py b/tests/test_parser.py index 54b4b68..a808299 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -23,6 +23,20 @@ def test_parse_key_value_message(self) -> None: self.assertEqual(fields["machine_code"], "ABC") self.assertEqual(fields["license_purpose"], "POC") + def test_parses_realistic_one_line_request(self) -> None: + fields = parse_message( + "申请雷池20系列测试授权 CRMID=67fce80803460ad9f6a7bbd9 " + "机器码=UAHD-PE7X-7P47-FS4O 申请目的=POC 使用场景=新签 期限=30", + self.catalog, + ) + + self.assertEqual(fields["product"], "雷池20系列") + self.assertEqual(fields["crm_id"], "67fce80803460ad9f6a7bbd9") + self.assertEqual(fields["machine_code"], "UAHD-PE7X-7P47-FS4O") + self.assertEqual(fields["license_purpose"], "POC") + self.assertEqual(fields["usage_scenario"], "新签") + self.assertEqual(fields["duration_days"], "30") + def test_detect_product_alias(self) -> None: fields = parse_message("给雷池 20申请授权 CRMID=CRM-123", self.catalog) diff --git a/tests/test_release_page.py b/tests/test_release_page.py index 8170393..c016d31 100644 --- a/tests/test_release_page.py +++ b/tests/test_release_page.py @@ -1,9 +1,59 @@ import unittest -from license_agent.release_page import FEATURE_NAMES +from license_agent.release_page import ( + FEATURE_NAMES, + normalize_option_label, + resolve_option_index, + set_radio_by_input_index, +) + + +class FakeRadio: + def __init__(self) -> None: + self.check_kwargs = None + + async def is_checked(self) -> bool: + return False + + async def check(self, **kwargs) -> None: + self.check_kwargs = kwargs + + +class FakeInputs: + def __init__(self, radio: FakeRadio) -> None: + self.radio = radio + + def nth(self, _index: int) -> FakeRadio: + return self.radio + + +class FakePage: + def __init__(self, radio: FakeRadio) -> None: + self.inputs = FakeInputs(radio) + + def locator(self, _selector: str) -> FakeInputs: + return self.inputs + + async def wait_for_timeout(self, _timeout: int) -> None: + pass class ReleasePageTest(unittest.TestCase): + def test_normalizes_release_option_whitespace(self) -> None: + self.assertEqual(normalize_option_label(" 雷池 20 系列\n"), "雷池20系列") + + def test_resolves_one_normalized_option(self) -> None: + self.assertEqual( + resolve_option_index(["其他产品", "雷池20系列"], "雷池 20 系列"), + 1, + ) + + def test_rejects_missing_or_ambiguous_options(self) -> None: + with self.assertRaisesRegex(RuntimeError, "未找到下拉选项"): + resolve_option_index(["其他产品"], "雷池 20 系列") + with self.assertRaisesRegex(RuntimeError, "匹配到多个下拉选项"): + resolve_option_index(["雷池20系列", "雷池 20 系列"], "雷池20系列") + def test_feature_names_cover_leichi_20_defaults(self) -> None: self.assertEqual( FEATURE_NAMES, @@ -20,5 +70,14 @@ def test_feature_names_cover_leichi_20_defaults(self) -> None: ) +class ReleasePageAsyncTest(unittest.IsolatedAsyncioTestCase): + async def test_forces_wrapped_radio_input_check(self) -> None: + radio = FakeRadio() + + await set_radio_by_input_index(FakePage(radio), 7) + + self.assertEqual(radio.check_kwargs, {"force": True}) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_stream.py b/tests/test_stream.py new file mode 100644 index 0000000..f2f0417 --- /dev/null +++ b/tests/test_stream.py @@ -0,0 +1,170 @@ +import asyncio +import unittest +from unittest.mock import patch + +import dingtalk_stream + +from license_agent.stream import ( + DingTalkStreamConfig, + DingTalkStreamRunner, + LicenseChatbotHandler, + RecentMessageIds, + extract_stream_message, +) + + +class StreamMessageTest(unittest.TestCase): + def test_extracts_text_message_from_dict(self) -> None: + message = extract_stream_message( + { + "msgtype": "text", + "text": {"content": " CRMID=CRM-123 "}, + "conversationId": "cid-1", + "senderNick": "张三", + "senderStaffId": "staff-1", + "msgId": "msg-1", + } + ) + + self.assertEqual(message.text, "CRMID=CRM-123") + self.assertEqual(message.conversation_id, "cid-1") + self.assertEqual(message.applicant, "张三") + self.assertEqual(message.msg_id, "msg-1") + self.assertEqual(message.sender_staff_id, "staff-1") + + def test_extracts_text_message_from_sdk_object(self) -> None: + raw = dingtalk_stream.ChatbotMessage.from_dict( + { + "msgtype": "text", + "text": {"content": "提交"}, + "conversationId": "cid-2", + "senderStaffId": "staff-2", + "msgId": "msg-2", + } + ) + + message = extract_stream_message(raw) + + self.assertEqual(message.text, "提交") + self.assertEqual(message.conversation_id, "cid-2") + self.assertEqual(message.applicant, "staff-2") + self.assertEqual(message.msg_id, "msg-2") + + def test_falls_back_to_sender_for_conversation(self) -> None: + message = extract_stream_message( + { + "msgtype": "text", + "text": {"content": "hello"}, + "senderStaffId": "staff-3", + } + ) + + self.assertEqual(message.conversation_id, "staff-3") + + +class StreamConfigTest(unittest.TestCase): + def test_disabled_config_does_not_require_credentials(self) -> None: + with patch.dict("os.environ", {"DINGTALK_STREAM_ENABLED": "false"}, clear=True): + config = DingTalkStreamConfig.from_env() + + self.assertFalse(config.enabled) + + def test_enabled_config_requires_credentials(self) -> None: + with patch.dict("os.environ", {"DINGTALK_STREAM_ENABLED": "true"}, clear=True): + with self.assertRaisesRegex(RuntimeError, "DINGTALK_STREAM_CLIENT_ID"): + DingTalkStreamConfig.from_env() + + def test_runner_is_none_when_disabled(self) -> None: + with patch.dict("os.environ", {"DINGTALK_STREAM_ENABLED": "false"}, clear=True): + runner = DingTalkStreamRunner.from_env(lambda: lambda *_args: "ok") + + self.assertIsNone(runner) + + +class RecentMessageIdsTest(unittest.TestCase): + def test_deduplicates_recent_message_ids(self) -> None: + recent = RecentMessageIds(max_size=2) + + self.assertTrue(recent.add_if_new("a")) + self.assertFalse(recent.add_if_new("a")) + self.assertTrue(recent.add_if_new("b")) + self.assertTrue(recent.add_if_new("c")) + self.assertTrue(recent.add_if_new("a")) + + +class LicenseChatbotHandlerTest(unittest.TestCase): + def test_process_replies_to_normal_message(self) -> None: + calls = [] + + class Handler(LicenseChatbotHandler): + def _reply_text(self, text: str, chatbot_message: object) -> None: + calls.append(("reply", text, chatbot_message)) + + handler = Handler( + reply_handler=lambda message, auto_submit: ( + f"{message.conversation_id}|{message.text}|{message.applicant}|{auto_submit}" + ), + config=DingTalkStreamConfig(enabled=True, client_id="id", client_secret="secret"), + ) + callback = type( + "Callback", + (), + { + "data": { + "msgtype": "text", + "text": {"content": "机器码=ABC"}, + "conversationId": "cid-4", + "senderNick": "李四", + "msgId": "msg-4", + } + }, + )() + + code, message = asyncio.run(handler.process(callback)) + + self.assertEqual(code, dingtalk_stream.AckMessage.STATUS_OK) + self.assertEqual(message, "OK") + self.assertEqual(calls[0][1], "cid-4|机器码=ABC|李四|False") + + def test_process_async_submit_acknowledges_immediately(self) -> None: + calls = [] + done = asyncio.Event() + + class Handler(LicenseChatbotHandler): + def _reply_text(self, text: str, chatbot_message: object) -> None: + calls.append(text) + if len(calls) == 2: + done.set() + + handler = Handler( + reply_handler=lambda message, auto_submit: f"final:{auto_submit}", + config=DingTalkStreamConfig( + enabled=True, + client_id="id", + client_secret="secret", + async_submit=True, + ), + ) + callback = type( + "Callback", + (), + { + "data": { + "msgtype": "text", + "text": {"content": "提交"}, + "conversationId": "cid-5", + "senderNick": "王五", + "msgId": "msg-5", + } + }, + )() + + code, message = asyncio.run(handler.process(callback)) + + self.assertEqual(code, dingtalk_stream.AckMessage.STATUS_OK) + self.assertEqual(message, "OK") + self.assertEqual(calls[0], "已收到提交指令,正在处理,请稍候。") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_web_auth_config.py b/tests/test_web_auth_config.py new file mode 100644 index 0000000..72b2dee --- /dev/null +++ b/tests/test_web_auth_config.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import os +import unittest +from unittest.mock import patch + +from license_agent.download import WebLicenseDownloader +from license_agent.executor import WebLicenseExecutor + + +class WebAuthConfigTest(unittest.TestCase): + def test_executor_reads_persistent_profile_from_environment(self) -> None: + with patch.dict( + os.environ, + {"RELEASE_USER_DATA_DIR": "/srv/profile"}, + clear=True, + ): + executor = WebLicenseExecutor.from_env() + + self.assertEqual(str(executor.auth.user_data_dir), "/srv/profile") + self.assertIsNone(executor.auth.storage_state_path) + + def test_downloader_reads_persistent_profile_from_environment(self) -> None: + with patch.dict( + os.environ, + {"RELEASE_USER_DATA_DIR": "/srv/profile"}, + clear=True, + ): + downloader = WebLicenseDownloader.from_env() + + self.assertEqual(str(downloader.auth.user_data_dir), "/srv/profile") + self.assertIsNone(downloader.auth.storage_state_path) + + def test_executor_rejects_conflicting_authentication_sources(self) -> None: + with patch.dict( + os.environ, + { + "RELEASE_STORAGE_STATE": "/srv/state.json", + "RELEASE_USER_DATA_DIR": "/srv/profile", + }, + clear=True, + ): + with self.assertRaisesRegex(ValueError, "RELEASE_STORAGE_STATE"): + WebLicenseExecutor.from_env() + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/check_release_login.py b/tools/check_release_login.py index bfa8fb1..2fc0961 100644 --- a/tools/check_release_login.py +++ b/tools/check_release_login.py @@ -2,24 +2,30 @@ import argparse import asyncio +import os import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from license_agent.login_state import check_live_login_state, summarize_storage_state +from license_agent.browser_session import BrowserAuthConfig async def main() -> None: parser = argparse.ArgumentParser(description="检查 Release 登录态文件是否还可用") - parser.add_argument( + auth_group = parser.add_mutually_exclusive_group() + auth_group.add_argument( "--storage", - default="config/release-storage-state.json", help="Playwright storage_state 文件路径", ) + auth_group.add_argument( + "--user-data-dir", + help="服务器持久 Chromium 用户目录", + ) parser.add_argument( "--url", - default="https://release.chaitin.net/license", + default="https://release.chaitin.net/license/apply", help="用于实时校验登录态的 Release 页面", ) parser.add_argument( @@ -29,27 +35,39 @@ async def main() -> None: ) args = parser.parse_args() - storage_path = Path(args.storage) - if not storage_path.is_file(): - raise SystemExit(f"登录态文件不存在:{storage_path}") - - summary = summarize_storage_state(storage_path) - print(f"登录态文件:{summary.path}") - print(f"Cookie 总数:{summary.cookie_count}") - print(f"会话 Cookie 数:{summary.session_cookie_count}") - if summary.earliest_expiry: - expiry = summary.earliest_expiry - status = "已过期" if expiry.expired else "未过期" - print( - "最早过期 Cookie:" - f"{expiry.name} ({expiry.domain}) -> " - f"{expiry.expires_at.astimezone().strftime('%Y-%m-%d %H:%M:%S %Z')},{status}" - ) + storage_value = args.storage or ( + None if args.user_data_dir else "config/release-storage-state.json" + ) + auth = BrowserAuthConfig.from_values(storage_value, args.user_data_dir) + + if auth.storage_state_path: + if not auth.storage_state_path.is_file(): + raise SystemExit(f"登录态文件不存在:{auth.storage_state_path}") + summary = summarize_storage_state(auth.storage_state_path) + print(f"登录态文件:{summary.path}") + print(f"Cookie 总数:{summary.cookie_count}") + print(f"会话 Cookie 数:{summary.session_cookie_count}") + if summary.earliest_expiry: + expiry = summary.earliest_expiry + status = "已过期" if expiry.expired else "未过期" + print( + "最早过期 Cookie:" + f"{expiry.name} ({expiry.domain}) -> " + f"{expiry.expires_at.astimezone().strftime('%Y-%m-%d %H:%M:%S %Z')},{status}" + ) + else: + print("没有带 expires 的持久 Cookie;实际失效时间由服务端会话控制。") else: - print("没有带 expires 的持久 Cookie;实际失效时间由服务端会话控制。") + if not auth.user_data_dir or not auth.user_data_dir.is_dir(): + raise SystemExit(f"浏览器用户目录不存在:{auth.user_data_dir}") + print(f"浏览器用户目录:{auth.user_data_dir}") if args.live: - live = await check_live_login_state(storage_path, args.url) + live = await check_live_login_state( + auth, + args.url, + lock_dir=os.getenv("RELEASE_DATA_DIR", "data"), + ) print(f"实时访问校验:{'有效' if live.valid else '已失效'}") print(f"最终 URL:{live.final_url}") if live.title: diff --git a/tools/open_release_profile.py b/tools/open_release_profile.py new file mode 100755 index 0000000..dd7c95d --- /dev/null +++ b/tools/open_release_profile.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import argparse +import asyncio + + +async def main() -> None: + parser = argparse.ArgumentParser(description="打开服务器 Release 持久浏览器") + parser.add_argument("--user-data-dir", required=True) + parser.add_argument( + "--url", + default="https://release.chaitin.net/license/apply", + ) + args = parser.parse_args() + + from playwright.async_api import async_playwright + + stop = asyncio.Event() + loop = asyncio.get_running_loop() + for signal_name in ("SIGINT", "SIGTERM"): + import signal + + loop.add_signal_handler(getattr(signal, signal_name), stop.set) + + async with async_playwright() as playwright: + context = await playwright.chromium.launch_persistent_context( + args.user_data_dir, + headless=False, + ) + page = context.pages[0] if context.pages else await context.new_page() + await page.goto(args.url) + print(f"Release browser ready: {page.url}", flush=True) + try: + await stop.wait() + finally: + await context.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tools/save_release_login.py b/tools/save_release_login.py index eb81f6f..50c1835 100644 --- a/tools/save_release_login.py +++ b/tools/save_release_login.py @@ -10,6 +10,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from license_agent.login_state import check_live_login_state, summarize_storage_state +from license_agent.browser_session import BrowserAuthConfig async def main() -> None: @@ -66,7 +67,11 @@ async def main() -> None: await browser.close() if not args.skip_live_check: - live = await check_live_login_state(tmp_path, args.url, headless=True) + live = await check_live_login_state( + BrowserAuthConfig.from_values(tmp_path, None), + args.url, + headless=True, + ) if not live.valid: tmp_path.unlink(missing_ok=True) raise SystemExit(