Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91057fa3b7 | |||
| 6b6d2969c3 | |||
| 82720ef2fa | |||
| 3a1ea6d97a | |||
| 75a332e7b8 | |||
| 4605d3d0e4 | |||
| beb95c1415 | |||
| e2eafb7407 | |||
| 96e5b9ea14 | |||
| 97d970dd1b | |||
| e3da478116 | |||
| ac76f89dc8 | |||
| c83841cf90 | |||
| d938885a49 |
84
SKILL.md
84
SKILL.md
@@ -1,68 +1,58 @@
|
||||
---
|
||||
name: 账号管理
|
||||
description: 多平台多账号管理。管理各平台账号与Chrome Profile的对应关系,供publisher类Skill调用获取账号信息。
|
||||
version: 1.0.4
|
||||
name: 账号与凭据管理
|
||||
description: "通用 Credential & Browser Profile Manager:管理网页账号、RPA 浏览器 profile、LLM/API 平台 API Key。支持物流平台(Maersk、COSCO、MSC 等)、LLM 平台(DeepSeek、豆包等)、内容平台。业务 skill 通过 CLI 获取账号,不直接读 SQLite。"
|
||||
version: 1.0.56
|
||||
author: 深圳匠厂科技有限公司
|
||||
metadata:
|
||||
openclaw:
|
||||
slug: account-manager
|
||||
emoji: "👤"
|
||||
emoji: "🔐"
|
||||
category: "通用"
|
||||
allowed-tools:
|
||||
- bash
|
||||
---
|
||||
|
||||
# 账号管理
|
||||
# 账号与凭据管理(account-manager)
|
||||
|
||||
## 使用时机
|
||||
通用 Credential & Browser Profile Manager。在本地 SQLite 管理平台注册、账号身份、凭据元数据(API Key/Token 不存明文)、RPA 并发锁(lease),供其它技能通过 CLI 调用 `scripts/main.py` 获取账号信息。
|
||||
|
||||
当用户发送以下内容的时候触发本Skill:
|
||||
- 说"初始化账号登录"、"登录搜狐账号"、"帮我登录账号"
|
||||
- 说"列出账号"、"查看所有账号"、"有哪些账号"
|
||||
- 说"添加账号"、"删除账号"、"获取账号信息"
|
||||
## 核心原则
|
||||
|
||||
## 执行步骤
|
||||
1. **account-manager 是唯一账号/凭据管理入口** — 业务 skill 不能自己存账号、密码、API Key、Token。
|
||||
2. **SQLite 只保存元数据** — `secret_ref`、`secret_mask`,不保存原始 secret 明文。
|
||||
3. **浏览器登录态** — 使用独立 `profile_dir` 保存 cookie/session,不保存网页登录密码。
|
||||
4. **业务 skill 只通过 CLI/API 拿账号** — 不直接读 SQLite。
|
||||
|
||||
### 添加账号(平台用中文名;须填合法中国大陆 11 位手机号;同平台下同手机号不可重复;ID 自增,名称如「搜狐1号」)
|
||||
```bash
|
||||
python3 {baseDir}/scripts/main.py add 搜狐号 189xxxxxxxx
|
||||
python3 {baseDir}/scripts/main.py add 知乎 13800138000
|
||||
```
|
||||
支持的平台称呼示例:搜狐号、头条号、知乎、微信公众号、Kimi、DeepSeek、豆包、通义千问、文心一言、腾讯元宝(亦支持英文键如 sohu、toutiao)。
|
||||
## 何时使用本技能
|
||||
|
||||
### 仅打开浏览器核对是否已登录(不写数据库)
|
||||
```bash
|
||||
python3 {baseDir}/scripts/main.py open <id>
|
||||
```
|
||||
1. **登记网页/RPA 账号**:`account add-web` — 平台 + 登录标识 + 自动生成 profile_dir。
|
||||
2. **登记 API Key/Token 账号**:`account add-secret` — 平台 + 凭据类型 + secret 存储方式。
|
||||
3. **查看/列出账号**:`account get/list` — JSON 输出。
|
||||
4. **为 RPA 挑选账号**:`account pick-web` — 按环境/租户/角色筛选,可带 lease。
|
||||
5. **获取 API Key**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。
|
||||
6. **浏览器打开查看**:`account open <id>`(或兼容别名 `open <id>`)— 仅查看,不做登录检测。
|
||||
7. **管理 lease**:`lease release/list/cleanup` — 防止 RPA 并发。
|
||||
|
||||
### 登录并自动检测、写入数据库
|
||||
```bash
|
||||
python3 {baseDir}/scripts/main.py login <id>
|
||||
```
|
||||
## CLI 调用
|
||||
|
||||
### 删除账号(同时删库里的记录与 profile 用户数据目录)
|
||||
```bash
|
||||
python3 {baseDir}/scripts/main.py delete id <id>
|
||||
python3 {baseDir}/scripts/main.py delete platform <平台>
|
||||
python3 {baseDir}/scripts/main.py delete platform <平台> <手机号>
|
||||
```
|
||||
语法、默认值、错误约定见 **`references/CLI.md`**。
|
||||
|
||||
### 列出某平台所有账号(platform 可为中文名、all 或 全部)
|
||||
```bash
|
||||
python3 {baseDir}/scripts/main.py list all
|
||||
python3 {baseDir}/scripts/main.py list 搜狐号
|
||||
```
|
||||
## 支持的平台
|
||||
|
||||
### 跨技能:单行 JSON(get / list-json / pick-web / pick-logged-in / set-login-status)
|
||||
```bash
|
||||
python3 {baseDir}/scripts/main.py get <id>
|
||||
python3 {baseDir}/scripts/main.py list-json all --limit 50
|
||||
python3 {baseDir}/scripts/main.py pick-web 搜狐号
|
||||
python3 {baseDir}/scripts/main.py set-login-status <id> 1
|
||||
```
|
||||
约定与字段说明见 `references/INTEGRATION.md`;错误前缀见 `references/ERRORS.md`。
|
||||
见 `references/PLATFORMS.md`;支持物流、LLM、内容三大业务域。
|
||||
|
||||
## 扩展文档(便于 AI 精确调用)
|
||||
## 数据与环境
|
||||
|
||||
- **references/**:完整 CLI、平台表、运行时环境、错误码、集成协议 — 见 [references/README.md](references/README.md)。
|
||||
- **assets/**:示例 JSON 与单条账号 Schema — 见 [assets/README.md](assets/README.md)。
|
||||
库路径、profile 布局、环境变量见 `references/RUNTIME.md`;表结构见 `references/SCHEMA.md`。
|
||||
|
||||
## 跨技能集成
|
||||
|
||||
`account get/list/pick-web` / `credential pick` 的 stdout 协议见 `references/INTEGRATION.md`。
|
||||
|
||||
## Secret 存储
|
||||
|
||||
- `none`:无 secret(浏览器 profile 登录态)
|
||||
- `env`:SQLite 保存环境变量名,运行时从 `os.environ` 读取
|
||||
- `windows_credential`:正式 Windows 部署,真实 secret 存 Windows Credential Manager
|
||||
|
||||
详见 `references/RUNTIME.md`。
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# assets
|
||||
|
||||
- `examples/`:CLI 成功输出形状示例(虚构路径与数据)。
|
||||
- `schemas/`:`get` / `list-json` / `pick-*` 单条对象的轻量 JSON Schema(`additionalProperties: true` 允许扩展字段)。
|
||||
- `schemas/`:`get` / `list-json` / `pick-web` 单条对象的轻量 JSON Schema(`additionalProperties: true` 允许扩展字段)。
|
||||
|
||||
详细命令与错误码见仓库根目录 `references/`。
|
||||
- 面向用户的介绍见 `references/README.md`。
|
||||
- 面向编排/CLI 的细节见 `references/CLI.md`、`ERRORS.md`、`INTEGRATION.md`(由 `SKILL.md` 渐进引用)。
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
# 示例 JSON
|
||||
|
||||
- `get-response.json`:`python main.py get <id>` 成功时 **stdout 单行** 对象的形状参考(示例路径为虚构)。
|
||||
- `list-json-array.json`:`list-json` 成功时 **stdout 单行** 数组的形状参考(本文件为多行排版便于阅读)。
|
||||
- `get-response.json`:`account get <id>` 成功时 stdout 单行对象的形状参考(v2 schema,展示为完整 JSON 对象)。
|
||||
- `list-json-array.json`:`account list` 或 `list-json` 成功时 stdout 单行数组的形状参考(多行排版便于阅读)。
|
||||
|
||||
注意:实际输出为 **`{"success":true,"data":...}`** 包裹的格式(get/list 系列),或直接输出数组(list-json 兼容入口)。
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
{
|
||||
"id": 1,
|
||||
"name": "搜狐1号",
|
||||
"platform": "sohu",
|
||||
"phone": "13800138000",
|
||||
"profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/搜狐号/13800138000",
|
||||
"url": "https://mp.sohu.com",
|
||||
"login_status": 1,
|
||||
"last_login_at": "2026-04-01T12:00:00",
|
||||
"created_at": "2026-03-01T10:00:00",
|
||||
"updated_at": "2026-04-01T12:00:00"
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 1,
|
||||
"platform_key": "maersk",
|
||||
"account_label": "Maersk simulator booking",
|
||||
"login_id": "demo@maersk.com",
|
||||
"login_id_type": "email",
|
||||
"tenant_id": "tenant-demo",
|
||||
"environment": "simulator",
|
||||
"role": "booking",
|
||||
"provider_code": "maersk",
|
||||
"profile_dir": "D:/claw-data/10032/account-manager/profiles/logistics/maersk/Maersk_simulator_booking_demo@maersk.com",
|
||||
"url": "http://localhost:5180/industries/logistics/maersk/login",
|
||||
"status": "active",
|
||||
"session_status": "unknown",
|
||||
"created_at": "2026-05-01T09:00:00",
|
||||
"updated_at": "2026-05-01T09:00:00"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
[
|
||||
{
|
||||
"id": 2,
|
||||
"name": "知乎1号",
|
||||
"platform": "zhihu",
|
||||
"phone": "13900001111",
|
||||
"profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/知乎/13900001111",
|
||||
"url": "https://www.zhihu.com",
|
||||
"login_status": 0,
|
||||
"last_login_at": null,
|
||||
"created_at": "2026-04-02T09:00:00",
|
||||
"updated_at": "2026-04-02T09:00:00"
|
||||
"id": 1,
|
||||
"platform_key": "maersk",
|
||||
"account_label": "Maersk simulator booking",
|
||||
"login_id": "demo@maersk.com",
|
||||
"login_id_type": "email",
|
||||
"tenant_id": "tenant-demo",
|
||||
"environment": "simulator",
|
||||
"role": "booking",
|
||||
"provider_code": "maersk",
|
||||
"profile_dir": "D:/claw-data/10032/account-manager/profiles/logistics/maersk/Maersk_simulator_booking_demo@maersk.com",
|
||||
"url": "http://localhost:5180/industries/logistics/maersk/login",
|
||||
"status": "active",
|
||||
"session_status": "unknown",
|
||||
"created_at": "2026-05-01T09:00:00",
|
||||
"updated_at": "2026-05-01T09:00:00"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"name": "搜狐1号",
|
||||
"platform": "sohu",
|
||||
"phone": "13800138000",
|
||||
"profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/搜狐号/13800138000",
|
||||
"url": "https://mp.sohu.com",
|
||||
"login_status": 1,
|
||||
"last_login_at": "2026-04-01T12:00:00",
|
||||
"created_at": "2026-03-01T10:00:00",
|
||||
"updated_at": "2026-04-01T12:00:00"
|
||||
"id": 2,
|
||||
"platform_key": "deepseek",
|
||||
"account_label": "DeepSeek production key",
|
||||
"login_id": "",
|
||||
"login_id_type": "unknown",
|
||||
"tenant_id": "",
|
||||
"environment": "production",
|
||||
"role": "api",
|
||||
"provider_code": "deepseek",
|
||||
"profile_dir": "",
|
||||
"url": "https://chat.deepseek.com",
|
||||
"status": "active",
|
||||
"session_status": "unknown",
|
||||
"created_at": "2026-05-01T10:00:00",
|
||||
"updated_at": "2026-05-01T10:00:00"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2,23 +2,47 @@
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://openclaw.local/account-manager/account-record.schema.json",
|
||||
"title": "AccountRecord",
|
||||
"description": "account-manager get / list-json / pick-* 返回的单条账号对象(核心字段;extra_json 合并键不枚举)",
|
||||
"description": "account-manager account get / pick-web / list 返回的单条账号对象(v2 schema)",
|
||||
"type": "object",
|
||||
"required": ["id", "name", "platform", "phone", "profile_dir", "url", "login_status", "created_at", "updated_at"],
|
||||
"required": ["id", "platform_key", "account_label", "environment", "role", "status", "session_status"],
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": { "type": "string" },
|
||||
"platform": { "type": "string", "description": "内部英文键,如 sohu、zhihu" },
|
||||
"phone": { "type": "string", "description": "归一后的数字串,可能为空字符串" },
|
||||
"id": { "type": "integer", "description": "账号主键" },
|
||||
"platform_key": { "type": "string", "description": "平台唯一键,如 maersk、deepseek" },
|
||||
"account_label": { "type": "string", "description": "人可读名称" },
|
||||
"login_id": { "type": "string", "description": "登录标识(email/phone/username/customer_code)" },
|
||||
"login_id_type": { "type": "string", "enum": ["email", "username", "phone", "customer_code", "api_account", "unknown"] },
|
||||
"tenant_id": { "type": "string", "description": "租户/项目标识" },
|
||||
"environment": { "type": "string", "enum": ["simulator", "staging", "production", "test"] },
|
||||
"role": { "type": "string", "description": "booking / tracking / admin / sales / api / default" },
|
||||
"provider_code": { "type": "string", "description": "供应商代码" },
|
||||
"profile_dir": { "type": "string", "description": "Playwright 用户数据目录绝对路径" },
|
||||
"url": { "type": "string", "format": "uri" },
|
||||
"login_status": { "type": "integer", "enum": [0, 1] },
|
||||
"last_login_at": {
|
||||
"type": ["string", "null"],
|
||||
"description": "本地时区 ISO8601 字符串;未登录过为 null"
|
||||
"url": { "type": "string", "description": "账号默认入口 URL" },
|
||||
"status": { "type": "string", "enum": ["active", "disabled", "needs_review"] },
|
||||
"session_status": { "type": "string", "enum": ["unknown", "likely_valid", "needs_login", "expired"] },
|
||||
"last_used_at": { "type": ["string", "null"], "description": "最近使用时间 ISO8601" },
|
||||
"last_login_check_at": { "type": ["string", "null"] },
|
||||
"last_error_code": { "type": "string" },
|
||||
"last_error_message": { "type": "string" },
|
||||
"created_at": { "type": ["string", "null"], "description": "创建时间 ISO8601" },
|
||||
"updated_at": { "type": ["string", "null"], "description": "更新时间 ISO8601" },
|
||||
"credentials": {
|
||||
"type": "array",
|
||||
"description": "关联凭据(仅 account get --with-credentials 时返回)",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"credential_label": { "type": "string" },
|
||||
"credential_type": { "type": "string" },
|
||||
"secret_storage": { "type": "string" },
|
||||
"secret_ref": { "type": "string" },
|
||||
"secret_mask": { "type": "string" },
|
||||
"status": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"created_at": { "type": ["string", "null"] },
|
||||
"updated_at": { "type": ["string", "null"] }
|
||||
"lease_token": { "type": "string", "description": "仅 pick-web --lease 时返回" },
|
||||
"lease_expires_at": { "type": "string", "description": "仅 pick-web --lease 时返回" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
|
||||
@@ -1,116 +1,160 @@
|
||||
# account-manager CLI 参考
|
||||
# account-manager CLI 速查
|
||||
|
||||
入口:`{skillRoot}/scripts/main.py`(宿主可将 `{baseDir}` 或技能根目录代入)。下文用 `python main.py` 表示在 `scripts/` 目录执行,或写成 `python {skillRoot}/scripts/main.py`。
|
||||
将 `{baseDir}` 替换为技能根目录。所有命令通过 `python {baseDir}/scripts/main.py` 调用(工作目录可为技能根或 `scripts/`,与命令中的路径一致即可)。下文示例简写为 `python main.py` 表示在 `scripts/` 下执行。
|
||||
|
||||
## 子命令与实现(与代码一致)
|
||||
|
||||
### 平台管理
|
||||
|
||||
| CLI | 说明 |
|
||||
|-----|------|
|
||||
| `platform list [--domain logistics\|llm\|content]` | 列出平台注册 |
|
||||
| `platform get <platform>` | 获取平台详情 |
|
||||
|
||||
### 账号管理
|
||||
|
||||
| CLI | 说明 |
|
||||
|-----|------|
|
||||
| `account add-web --platform <p> --login-id <id> [...]` | 创建网页/RPA 账号 |
|
||||
| `account add-secret --platform <p> --credential-type <type> [...]` | 创建 API Key/Token 凭据 |
|
||||
| `account get <id> [--with-credentials]` | 获取单条账号 JSON |
|
||||
| `account list [--platform <p>] [--environment <env>] [--tenant-id <t>] [--role <role>]` | 列出账号 JSON 数组 |
|
||||
| `account pick-web --platform <p> [--lease] [--ttl-sec 900]` | 挑选网页自动化候选 |
|
||||
| `account mark-session <id> --session-status <status>` | 标记登录状态 |
|
||||
| `account mark-error <id> --code <code> --message <msg>` | 标记错误 |
|
||||
| `account mark-used <id>` | 标记已使用 |
|
||||
| `account open <id>` | 打开持久化浏览器 profile(仅查看) |
|
||||
|
||||
### 凭据管理
|
||||
|
||||
| CLI | 说明 |
|
||||
|-----|------|
|
||||
| `credential pick --platform <p> [--type <type>] [--reveal]` | 挑选凭据(默认隐藏 secret) |
|
||||
| `credential resolve --id <credential_id> [--reveal]` | 按 ID 解析凭据 |
|
||||
|
||||
### 租约管理
|
||||
|
||||
| CLI | 说明 |
|
||||
|-----|------|
|
||||
| `lease release <lease_token>` | 释放租约 |
|
||||
| `lease list [--active]` | 列出租约 |
|
||||
| `lease cleanup` | 清理过期租约 |
|
||||
|
||||
### 浏览器
|
||||
|
||||
| CLI | 说明 |
|
||||
|-----|------|
|
||||
| `account open <id>` | 打开浏览器查看(仅查看) |
|
||||
| `open <id>` | 同上(兼容别名) |
|
||||
|
||||
### 兼容入口(旧版别名)
|
||||
|
||||
| CLI | 等价于 |
|
||||
|-----|--------|
|
||||
| `get <id>` | `account get <id>` |
|
||||
| `list-json <platform\|all> [--limit N]` | JSON 数组输出 |
|
||||
| `pick-web <platform>` | `account pick-web --platform <platform>` |
|
||||
| `list [platform\|all]` | 人类可读列表 |
|
||||
| `add <platform> <phone>` | `account add-web`(phone 作为 login_id) |
|
||||
| `delete id\|platform <...>` | 删除账号 |
|
||||
|
||||
---
|
||||
|
||||
## account add-web
|
||||
|
||||
```bash
|
||||
python main.py account add-web \
|
||||
--platform maersk \
|
||||
--login-id demo@maersk.com \
|
||||
--login-id-type email \
|
||||
--label "Maersk simulator booking" \
|
||||
--tenant-id tenant-demo \
|
||||
--environment simulator \
|
||||
--role booking \
|
||||
--provider-code maersk \
|
||||
--url http://localhost:5180/industries/logistics/maersk/login
|
||||
```
|
||||
|
||||
- `--platform`(必填):平台键/别名
|
||||
- `--login-id`:登录标识(email/username/phone/customer_code)
|
||||
- `--login-id-type`:默认 `unknown`
|
||||
- `--label`:人可读名称,不填则自动拼接
|
||||
- `--tenant-id`:租户/项目标识
|
||||
- `--environment`:默认 `production`
|
||||
- `--role`:默认 `default`
|
||||
- `--provider-code`:默认取平台 provider_code
|
||||
- `--url`:默认取平台 default_url
|
||||
- `--extra-json`:扩展 JSON
|
||||
- `--profile-dir`:高级调试用,不推荐
|
||||
|
||||
输出 JSON,包含 `id`、`profile_dir`、`url` 等。
|
||||
|
||||
## account add-secret
|
||||
|
||||
```bash
|
||||
# env 方式
|
||||
python main.py account add-secret \
|
||||
--platform deepseek \
|
||||
--credential-type api_key \
|
||||
--label "DeepSeek env key" \
|
||||
--secret-storage env \
|
||||
--secret-ref DEEPSEEK_API_KEY \
|
||||
--environment production \
|
||||
--role api
|
||||
|
||||
# windows_credential 方式
|
||||
echo "sk-test-1234" | python main.py account add-secret \
|
||||
--platform deepseek \
|
||||
--credential-type api_key \
|
||||
--label "DeepSeek production key" \
|
||||
--secret-storage windows_credential \
|
||||
--secret-stdin \
|
||||
--environment production \
|
||||
--role api
|
||||
```
|
||||
|
||||
- `--secret-storage`:`env` / `windows_credential` / `none`
|
||||
- `--secret-stdin`:从 stdin 读取 secret(仅 windows_credential)
|
||||
- `--secret-ref`:环境变量名(仅 env)
|
||||
- `none` 仅允许 `credential_type=browser_profile` 或 `note`
|
||||
|
||||
输出 JSON,包含 `account_id`(未指定 `--account-id` 时会自动创建一条用于挂载凭据的账号记录)、`credential_id`、`secret_mask`、`secret_ref`,**不包含 secret 明文**。
|
||||
|
||||
## account pick-web
|
||||
|
||||
```bash
|
||||
python main.py account pick-web \
|
||||
--platform maersk \
|
||||
--environment simulator \
|
||||
--tenant-id tenant-demo \
|
||||
--role booking \
|
||||
--lease \
|
||||
--ttl-sec 900 \
|
||||
--holder monitor-competitor-rates \
|
||||
--purpose maersk_sim_rpa
|
||||
```
|
||||
|
||||
- 筛选 status=active 且未被 active lease 占用的账号
|
||||
- 优先 session_status != expired
|
||||
- 优先 last_used_at 为空的账号
|
||||
- `--lease`:创建 lease,返回 `lease_token` 和 `lease_expires_at`
|
||||
- `--ttl-sec`:租约时长,默认 900 秒
|
||||
|
||||
## credential pick
|
||||
|
||||
成功时输出单行 JSON:`{"success": true, ...fields }`;失败时为 [ERRORS.md](ERRORS.md) 的机器 JSON。
|
||||
|
||||
```bash
|
||||
# 默认不返回 secret
|
||||
python main.py credential pick --platform deepseek --type api_key --environment production --role api
|
||||
|
||||
# --reveal 才返回 secret 明文
|
||||
python main.py credential pick --platform deepseek --type api_key --environment production --role api --reveal
|
||||
```
|
||||
|
||||
## 通用说明
|
||||
|
||||
- **平台参数**:可为 `scripts/util/platforms.py` 中配置的**中文展示名、别名或英文键**(如 `sohu`、`搜狐号`)。详见 [PLATFORMS.md](PLATFORMS.md)。
|
||||
- **数据与路径**:库文件、profile 目录由环境变量决定,网关与本地终端不一致时会「看不到同一份数据」。详见 [RUNTIME.md](RUNTIME.md)。
|
||||
- **机器可读子命令**:`get`、`list-json`、`pick-logged-in`、`pick-web`、`set-login-status` 的成功/失败约定见 [INTEGRATION.md](INTEGRATION.md)。
|
||||
- **错误码前缀**:见 [ERRORS.md](ERRORS.md)。
|
||||
|
||||
> 说明:当前版本 **未实现** `health` / `version` 子命令;与工作区其它技能的统一约定可对齐后另补。
|
||||
|
||||
---
|
||||
|
||||
## 子命令一览
|
||||
|
||||
### `add` — 添加账号
|
||||
|
||||
```bash
|
||||
python main.py add <平台> <手机号>
|
||||
```
|
||||
|
||||
- 手机号为**中国大陆 11 位**(`1` 开头,第二位 `3–9`),可含空格/分隔符,入库前会归一成数字。
|
||||
- 同一 `platform` 下同一手机号不可重复。
|
||||
|
||||
### `list` — 列出账号(人类可读多行块)
|
||||
|
||||
```bash
|
||||
python main.py list [平台|all|全部]
|
||||
```
|
||||
|
||||
- 省略第二参数时等价于 `all`。
|
||||
- 默认最多 **10** 条(按创建时间倒序);内部实现见 `service/account_service.cmd_list`。
|
||||
|
||||
### 简写:`python main.py <平台>` / `python main.py <平台> list`
|
||||
|
||||
- 若第一个参数能解析为平台,则等价于 `list <平台>`。
|
||||
|
||||
### `get` — 按 id 输出**单行 JSON**
|
||||
|
||||
```bash
|
||||
python main.py get <id>
|
||||
```
|
||||
|
||||
- 成功:`stdout` **仅一行** JSON 对象(UTF-8)。
|
||||
- 失败:首行 `ERROR:ACCOUNT_NOT_FOUND`,路径提示在 stderr。
|
||||
|
||||
### `list-json` — 列表的**单行 JSON 数组**(跨技能)
|
||||
|
||||
```bash
|
||||
python main.py list-json <平台|all|全部> [--limit N]
|
||||
```
|
||||
|
||||
- 成功:`stdout` **仅一行** JSON 数组;元素结构与 `get` 单条一致。
|
||||
- 默认 `limit=200`,实际 clamp 为 `1..500`。
|
||||
- 排序:按 `updated_at` 倒序。
|
||||
|
||||
### `pick-logged-in` — 该平台**已登录**的一条账号(单行 JSON)
|
||||
|
||||
```bash
|
||||
python main.py pick-logged-in <平台>
|
||||
```
|
||||
|
||||
- 筛选:`login_status = 1`,按 `last_login_at` 优先。
|
||||
- 失败:首行以 `ERROR:` 开头(**勿当 JSON 解析**)。
|
||||
|
||||
### `pick-web` — 网页自动化候选账号(单行 JSON)
|
||||
|
||||
```bash
|
||||
python main.py pick-web <平台>
|
||||
```
|
||||
|
||||
- 优先同 `pick-logged-in`;若无已登录,则取该平台 `updated_at` 最新一条。
|
||||
- 失败:首行 `ERROR:`。
|
||||
|
||||
### `set-login-status` — 回写登录态(跨技能)
|
||||
|
||||
```bash
|
||||
python main.py set-login-status <id> <0|1>
|
||||
```
|
||||
|
||||
- 成功:`stdout` 首行 `OK:SET_LOGIN_STATUS` 或 `OK:CLEARED_LOGIN_STATUS`,退出码 `0`。
|
||||
- 失败:`stdout` 首行 `ERROR:...`,退出码 `1`。
|
||||
|
||||
### `open` — 仅打开浏览器(**不写库**)
|
||||
|
||||
```bash
|
||||
python main.py open <id>
|
||||
```
|
||||
|
||||
- 需本机 Chrome/Edge + Playwright;详见运行环境说明。
|
||||
|
||||
### `login` — 打开浏览器并**自动检测登录**,写回 `login_status`
|
||||
|
||||
```bash
|
||||
python main.py login <id>
|
||||
```
|
||||
|
||||
- 依赖 Playwright;超时与轮询间隔等见 [RUNTIME.md](RUNTIME.md)。
|
||||
|
||||
### `delete` — 删除库记录并尽量删除 profile 目录
|
||||
|
||||
```bash
|
||||
python main.py delete id <id>
|
||||
python main.py delete platform <平台>
|
||||
python main.py delete platform <平台> <手机号>
|
||||
```
|
||||
|
||||
- 第二段必须为 `id` 或 `platform`(小写不敏感)。
|
||||
|
||||
---
|
||||
|
||||
## 无参数 / 未知子命令
|
||||
|
||||
- 无参数或无法解析:打印用法概览;未知子命令时首行 `ERROR:CLI_UNKNOWN_OR_BAD_ARGS`,退出码 `1`。
|
||||
- **平台参数**:可为 `scripts/util/platforms.py` 中配置的**展示名、别名或英文键**(如 `sohu`、`搜狐号`、`maersk`、`马士基`)。详见 [PLATFORMS.md](PLATFORMS.md)。
|
||||
- **数据与路径**:库文件、profile 目录由环境变量决定。详见 [RUNTIME.md](RUNTIME.md)。
|
||||
- **机器可读命令**:成功时输出单行 JSON;失败时首行 `ERROR:`。详见 [INTEGRATION.md](INTEGRATION.md)。
|
||||
- **错误码**:见 [ERRORS.md](ERRORS.md)。
|
||||
|
||||
@@ -1,56 +1,79 @@
|
||||
# 终端前缀约定(account-manager)
|
||||
# 终端错误码(account-manager)
|
||||
|
||||
约定:**机器解析只依赖 `stdout` 首行**时,应先判断首行是否以 `ERROR:` 或 `OK:` 开头;多行说明可忽略或仅作展示。
|
||||
约定:**机器解析只依赖 `stdout` 首行**时,应先判断首行是否以 `ERROR:` 开头;多行说明可忽略或仅作展示。
|
||||
|
||||
## `OK:`(成功标记)
|
||||
## 新增机器 JSON 失败格式
|
||||
|
||||
| 首行 | 场景 |
|
||||
|------|------|
|
||||
| `OK:SET_LOGIN_STATUS` | `set-login-status <id> 1` 成功 |
|
||||
| `OK:CLEARED_LOGIN_STATUS` | `set-login-status <id> 0` 成功 |
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "ERROR:...",
|
||||
"message": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## `ERROR:` — CLI 参数 / 路由
|
||||
## ERROR:CLI — CLI 参数 / 路由
|
||||
|
||||
| 前缀 | 含义 |
|
||||
|------|------|
|
||||
| `ERROR:CLI_ADD_MISSING_ARGS` | `add` 参数不足 |
|
||||
| `ERROR:CLI_GET_MISSING_ID` | `get` 缺少 id |
|
||||
| `ERROR:CLI_OPEN_MISSING_ID` | `open` 缺少 id |
|
||||
| `ERROR:CLI_LOGIN_MISSING_ID` | `login` 缺少 id |
|
||||
| `ERROR:CLI_DELETE_MISSING_ARGS` | `delete` 参数不足 |
|
||||
| `ERROR:CLI_DELETE_BAD_MODE` | `delete` 第二段不是 `id`/`platform` |
|
||||
| `ERROR:CLI_SET_LOGIN_STATUS_MISSING_ARGS` | `set-login-status` 参数不足 |
|
||||
| `ERROR:CLI_LIST_JSON_MISSING_PLATFORM` | `list-json` 缺少平台参数 |
|
||||
| `ERROR:CLI_PICK_LOGGED_IN_MISSING_ARGS` | `pick-logged-in` 缺少平台 |
|
||||
| `ERROR:CLI_PICK_WEB_MISSING_ARGS` | `pick-web` 缺少平台 |
|
||||
| `ERROR:CLI_BAD_ARGS` | 子命令参数缺失或非法 |
|
||||
| `ERROR:CLI_UNKNOWN_OR_BAD_ARGS` | 无法识别的子命令或参数组合 |
|
||||
|
||||
## `ERROR:` — 业务与数据
|
||||
## ERROR: — 数据库
|
||||
|
||||
| 前缀 | 含义 |
|
||||
|------|------|
|
||||
| `ERROR:INVALID_PLATFORM_LIST` | `list` 的平台名无法识别 |
|
||||
| `ERROR:INVALID_PLATFORM_LIST_JSON` | `list-json` 的平台名无法识别 |
|
||||
| `ERROR:INVALID_PLATFORM` | 其它子命令中平台无法识别 |
|
||||
| `ERROR:NO_ACCOUNTS_FOUND` | 列表无结果(或 `delete platform` 下无记录) |
|
||||
| `ERROR:ACCOUNT_NOT_FOUND` | 指定 id/记录不存在 |
|
||||
| `ERROR:PHONE_REQUIRED` | 缺少手机号 |
|
||||
| `ERROR:PHONE_INVALID` | 手机号格式不符合大陆 11 位规则 |
|
||||
| `ERROR:DUPLICATE_PHONE_PLATFORM` | 同平台下山手机号已存在 |
|
||||
| `ERROR:DELETE_INVALID_ID` | 删除 id 非法 |
|
||||
| `ERROR:NO_LOGGED_IN_ACCOUNT` | `pick-logged-in` 无 `login_status=1` 的账号 |
|
||||
| `ERROR:NO_ACCOUNT` | `pick-web` 该平台无任何记录 |
|
||||
| `ERROR:INVALID_ACCOUNT_ID` | `set-login-status` 的 id 非数字 |
|
||||
| `ERROR:INVALID_STATUS` | `set-login-status` 状态不是 0/1 |
|
||||
| `ERROR:DB_ERROR` | SQLite 写入失败等 |
|
||||
|
||||
## `ERROR:` — 浏览器 / 环境
|
||||
## ERROR: — 平台与账号
|
||||
|
||||
| 前缀 | 含义 |
|
||||
|------|------|
|
||||
| `ERROR:需要 playwright:pip install playwright && playwright install chromium` | 未安装 Playwright 或浏览器未安装 |
|
||||
| `ERROR:INVALID_PLATFORM` | 平台名无法识别 |
|
||||
| `ERROR:NO_ACCOUNTS_FOUND` | 列表无结果 |
|
||||
| `ERROR:ACCOUNT_NOT_FOUND` | 指定 id 不存在 |
|
||||
| `ERROR:ACCOUNT_DISABLED` | 账号状态为 disabled |
|
||||
| `ERROR:NO_ACCOUNT` | `pick-web` 无符合条件的可用账号(含:均被 active 未过期 lease 占用时) |
|
||||
|
||||
## ERROR: — 租约
|
||||
|
||||
| 前缀 | 含义 |
|
||||
|------|------|
|
||||
| `ERROR:LEASE_CONFLICT` | 同账号已有 active 未过期 lease |
|
||||
| `ERROR:LEASE_NOT_FOUND` | 未找到活跃租约 |
|
||||
| `ERROR:LEASE_EXPIRED` | 租约已过期 |
|
||||
|
||||
## ERROR: — 凭据与 Secret
|
||||
|
||||
| 前缀 | 含义 |
|
||||
|------|------|
|
||||
| `ERROR:CREDENTIAL_NOT_FOUND` | 凭据不存在 |
|
||||
| `ERROR:CREDENTIAL_DISABLED` | 凭据不可用 |
|
||||
| `ERROR:SECRET_STORAGE_UNSUPPORTED` | 不支持的 secret 存储方式 |
|
||||
| `ERROR:SECRET_REF_MISSING` | env 存储缺少环境变量名 |
|
||||
| `ERROR:SECRET_READ_FAILED` | 读取 secret 失败 |
|
||||
| `ERROR:SECRET_WRITE_FAILED` | 写入 secret 失败 |
|
||||
| `ERROR:SECRET_ENV_MISSING` | 环境变量未设置 |
|
||||
| `ERROR:WINDOWS_CREDENTIAL_UNAVAILABLE` | 非 Windows 平台不支持 |
|
||||
|
||||
## ERROR: — 浏览器 / 环境
|
||||
|
||||
| 前缀 | 含义 |
|
||||
|------|------|
|
||||
| `ERROR:需要 playwright Python 包` | 未安装 `playwright` 包或本机无 Chrome/Edge |
|
||||
| `ERROR:PROFILE_DIR_MISSING` | 账号记录中 `profile_dir` 为空 |
|
||||
|
||||
## 非前缀输出
|
||||
|
||||
- **成功添加账号**、**删除成功**等可能包含 `✅` 等人类可读行,**不宜**作为稳定协议解析。
|
||||
- **`get` / `list-json` / `pick-*` 成功**:`stdout` 应为**单行 JSON**(失败时首行 `ERROR:`,勿当 JSON)。
|
||||
- **`get` / `list-json` / `pick-web`** 成功时:`stdout` 应为**单行 JSON**。
|
||||
- 失败时首行 `ERROR:`,可能有后续说明行。
|
||||
- **新增机器命令建议统一 JSON 输出**,旧兼容命令可继续输出 `ERROR:` 文本前缀。
|
||||
|
||||
@@ -1,39 +1,94 @@
|
||||
# 与其它 Skill 集成(account-manager)
|
||||
|
||||
## 核心约束
|
||||
|
||||
业务 skill **不能自己存账号、密码、API Key、Token**。必须通过 account-manager CLI 获取。
|
||||
|
||||
## 推荐调用方式
|
||||
|
||||
使用 **`subprocess`** 调用技能根目录下的 `scripts/main.py`,工作目录可为技能根或 `scripts/`(只要命令中的 `python` 与路径一致)。宿主注入的 **`JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`** 必须与最终用户会话一致,否则读写到错误库。
|
||||
使用 **`subprocess`** 调用 `scripts/main.py`,工作目录可为技能根或 `scripts/`。宿主注入的 **`JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`** 必须与最终用户会话一致。
|
||||
|
||||
## 机器可读命令摘要
|
||||
|
||||
| 目的 | 命令 | 成功时 stdout |
|
||||
|------|------|----------------|
|
||||
| 取单条账号 JSON | `get <id>` | 单行 JSON 对象 |
|
||||
| 批量账号 JSON | `list-json <平台或all> [--limit N]` | 单行 JSON 数组 |
|
||||
| 只要已登录 | `pick-logged-in <平台>` | 单行 JSON 对象 |
|
||||
| 自动化候选(优先已登录) | `pick-web <平台>` | 单行 JSON 对象 |
|
||||
| 回写登录态 | `set-login-status <id> <0或1>` | 首行 `OK:...` |
|
||||
| 取单条账号 JSON | `account get <id>` | 单行 JSON 对象 |
|
||||
| 批量账号 JSON | `account list --platform <p> [--environment <e>]` | 单行 JSON 数组 |
|
||||
| 网页自动化候选 | `account pick-web --platform <p> [--lease]` | 单行 JSON 对象 |
|
||||
| 获取 API Key | `credential pick --platform <p> --type api_key [--reveal]` | 单行 JSON(`success: true` + 字段) |
|
||||
| 兼容入口 | `get <id>` / `list-json <p>` / `pick-web <p>` | 同上 |
|
||||
|
||||
## 解析协议(强建议)
|
||||
## 解析协议
|
||||
|
||||
1. 读取子进程 **stdout** 全文或首行。
|
||||
2. 若首行以 **`ERROR:`** 开头 → 失败,不要 `json.loads` 整段 stdout。
|
||||
3. 若首行以 **`OK:`** 开头 → `set-login-status` 成功(可再检查退出码 `0`)。
|
||||
4. **`get` / `list-json` / `pick-logged-in` / `pick-web`**:成功时应对**第一行**做 `json.loads`;后续行应不存在,若有则视为非协议内容。
|
||||
1. 读取子进程 **stdout** 首行。
|
||||
2. 若首行以 **`ERROR:`** 开头 → 失败。
|
||||
3. 否则对首行做 `json.loads`。
|
||||
|
||||
## JSON 对象字段(与 `get` 一致)
|
||||
## JSON 对象字段
|
||||
|
||||
典型字段见仓库 `assets/examples/get-response.json` 与 `assets/schemas/account-record.schema.json`。
|
||||
### account get / pick-web
|
||||
|
||||
常见键名包括:`id`, `name`, `platform`, `phone`, `profile_dir`, `url`, `login_status`, `last_login_at`, `created_at`, `updated_at`;`extra_json` 列中的扁平扩展可能并入同一对象。
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"platform_key": "maersk",
|
||||
"account_label": "Maersk simulator booking",
|
||||
"login_id": "demo@maersk.com",
|
||||
"login_id_type": "email",
|
||||
"tenant_id": "tenant-demo",
|
||||
"environment": "simulator",
|
||||
"role": "booking",
|
||||
"provider_code": "maersk",
|
||||
"profile_dir": "D:/claw-data/10032/account-manager/profiles/logistics/maersk/Maersk_simulator_booking_demo@maersk.com",
|
||||
"url": "http://localhost:5180/industries/logistics/maersk/login",
|
||||
"status": "active",
|
||||
"session_status": "unknown",
|
||||
"last_used_at": "2026-05-01T10:00:00",
|
||||
"created_at": "2026-05-01T09:00:00",
|
||||
"updated_at": "2026-05-01T09:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
## Publisher / 自动化流水线示例
|
||||
pick-web 额外字段(带 lease 时):
|
||||
- `lease_token`: 租约 token
|
||||
- `lease_expires_at`: 过期时间
|
||||
|
||||
1. `pick-web 搜狐号`(或目标平台)→ 解析 JSON → 取 `id`、`profile_dir`、`url`。
|
||||
2. 若需确认已登录:检查 `login_status == 1`,否则可引导用户执行 `login <id>` 或先 `open <id>` 人工登录。
|
||||
3. 外部脚本在确认登录后,可调用 `set-login-status <id> 1` 保持与库一致(若你们自有检测逻辑)。
|
||||
### credential pick
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"id": 1,
|
||||
"platform_key": "deepseek",
|
||||
"credential_label": "DeepSeek production key",
|
||||
"credential_type": "api_key",
|
||||
"secret_storage": "env",
|
||||
"secret_ref": "DEEPSEEK_API_KEY",
|
||||
"secret_mask": "****7890",
|
||||
"status": "active"
|
||||
}
|
||||
```
|
||||
|
||||
**只有 `--reveal` 时才返回 `secret` 字段。业务 skill 不要向最终用户展示 reveal 输出。**
|
||||
|
||||
失败时使用统一 JSON:`{"success": false, "error": {"code": "ERROR:...", "message": "..."}}`(见 [ERRORS.md](ERRORS.md))。
|
||||
|
||||
## 业务流程示例
|
||||
|
||||
### 物流 RPA
|
||||
|
||||
1. `account pick-web --platform maersk --environment simulator --role booking --lease --holder my-skill`
|
||||
2. 解析 JSON → 取 `id`、`profile_dir`、`url`、`lease_token`
|
||||
3. 用 `profile_dir` 启动持久化浏览器上下文
|
||||
4. 业务完成后:`lease release <lease_token>`
|
||||
5. 如遇登录失效:`account mark-session <id> --session-status needs_login`
|
||||
|
||||
### LLM API 调用
|
||||
|
||||
1. `credential pick --platform deepseek --type api_key --environment production --reveal`
|
||||
2. 解析 JSON → 取 `secret` 字段
|
||||
3. 在业务进程内存中使用,不写文件、不打日志
|
||||
|
||||
## 退出码
|
||||
|
||||
- **`set-login-status`**:失败时进程以 **`1`** 退出(见实现)。
|
||||
- 其它子命令多数为 **`0`**;若需严格编排,请以 **stdout 首行前缀** 与 **JSON 可解析性** 为准,不要仅依赖退出码。
|
||||
请以 **stdout 首行前缀** 与 **JSON 可解析性** 为准;不要仅依赖退出码。
|
||||
|
||||
@@ -1,23 +1,50 @@
|
||||
# 平台与别名(account-manager)
|
||||
|
||||
**权威来源**:`scripts/util/platforms.py` 中的 `PLATFORMS`。下表便于 AI/文档阅读;若与代码不一致,**以代码为准**。
|
||||
**权威来源**:`scripts/util/platforms.py` 中的 `PLATFORMS`。下表便于文档阅读;若与代码不一致,**以代码为准**。
|
||||
|
||||
入库字段 `accounts.platform` 使用下表 **英文键**(小写)。CLI 与用户话术可使用 **展示名** 或 **别名**。
|
||||
## 物流平台
|
||||
|
||||
| 英文键 (`platform`) | 展示名 (`label`) | 默认账号名前缀 (`prefix`) | 别名 (`aliases`) | 默认 URL |
|
||||
|---------------------|------------------|---------------------------|------------------|----------|
|
||||
| `sohu` | 搜狐号 | 搜狐 | 搜狐 | https://mp.sohu.com |
|
||||
| `toutiao` | 头条号 | 头条 | 头条 | https://mp.toutiao.com/ |
|
||||
| `zhihu` | 知乎 | (同 label) | 知乎号 | https://www.zhihu.com |
|
||||
| `wechat` | 微信公众号 | 微信 | 公众号、微信 | https://mp.weixin.qq.com |
|
||||
| `kimi` | Kimi | (同 label) | 月之暗面 | https://kimi.moonshot.cn |
|
||||
| `deepseek` | DeepSeek | (同 label) | — | https://chat.deepseek.com |
|
||||
| `doubao` | 豆包 | (同 label) | — | https://www.doubao.com |
|
||||
| `qianwen` | 通义千问 | 通义 | 通义、千问 | https://tongyi.aliyun.com |
|
||||
| `yiyan` | 文心一言 | 文心 | 文心、一言 | https://yiyan.baidu.com |
|
||||
| `yuanbao` | 腾讯元宝 | 元宝 | 元宝 | https://yuanbao.tencent.com |
|
||||
| 英文键 | 展示名 | 供应商代码 | 别名 |
|
||||
|--------|--------|-----------|------|
|
||||
| `maersk` | Maersk | maersk | 马士基 |
|
||||
| `cosco` | COSCO | cosco | 中远海运 |
|
||||
| `msc` | MSC | msc | 地中海航运 |
|
||||
| `cma_cgm` | CMA CGM | cma_cgm | 达飞 |
|
||||
| `evergreen` | Evergreen | evergreen | 长荣 |
|
||||
| `cargo_wise` | CargoWise | cargo_wise | Cargo Wise |
|
||||
| `freightos` | Freightos | freightos | — |
|
||||
| `webcargo` | WebCargo | webcargo | Web Cargo |
|
||||
| `sinotrans` | Sinotrans | sinotrans | 中外运 |
|
||||
|
||||
## 解析规则(摘要)
|
||||
## LLM / AI 平台
|
||||
|
||||
- 英文键本身、各 `label`、各 `aliases` 条目(及小写形式)均可解析到对应键。
|
||||
- 无法识别时,相关子命令会输出 `ERROR:INVALID_PLATFORM` 或 `ERROR:INVALID_PLATFORM_LIST` / `INVALID_PLATFORM_LIST_JSON` 等,并附带「支持的平台」提示。
|
||||
| 英文键 | 展示名 | 别名 | 默认 URL |
|
||||
|--------|--------|------|----------|
|
||||
| `deepseek` | DeepSeek | — | https://chat.deepseek.com |
|
||||
| `doubao` | Doubao | 豆包 | https://www.doubao.com |
|
||||
| `kimi` | Kimi | 月之暗面 | https://kimi.moonshot.cn |
|
||||
| `qianwen` | Qianwen | 通义千问、通义、千问 | https://tongyi.aliyun.com |
|
||||
| `yiyan` | YiYan | 文心一言、文心、一言 | https://yiyan.baidu.com |
|
||||
| `yuanbao` | Yuanbao | 腾讯元宝、元宝 | https://yuanbao.tencent.com |
|
||||
| `gemini` | Gemini | 谷歌Gemini、Google Gemini、Bard | https://gemini.google.com |
|
||||
| `minimax` | MiniMax | — | — |
|
||||
|
||||
## 内容平台
|
||||
|
||||
| 英文键 | 展示名 | 别名 | 默认 URL |
|
||||
|--------|--------|------|----------|
|
||||
| `sohu` | 搜狐号 | 搜狐 | https://mp.sohu.com |
|
||||
| `toutiao` | 头条号 | 头条 | https://mp.toutiao.com/ |
|
||||
| `zhihu` | 知乎 | 知乎号 | https://www.zhihu.com |
|
||||
| `wechat` | 微信公众号 | 公众号、微信 | https://mp.weixin.qq.com |
|
||||
|
||||
## 解析规则
|
||||
|
||||
- 英文键本身、各 `display_name`、各 `aliases` 条目(及小写形式)均可解析到对应键。
|
||||
- 不区分大小写。
|
||||
- 无法识别时输出 `ERROR:INVALID_PLATFORM`,并附带支持的平台列表。
|
||||
- 解析函数:`resolve_platform_key(name)` — 支持 key、display_name、aliases。
|
||||
|
||||
## 平台注册到数据库
|
||||
|
||||
`seed_platforms(conn)` 在每次 `init_db()` 后自动执行,将 `PLATFORMS` 字典 upsert 到 `platforms` 表。
|
||||
|
||||
@@ -1,13 +1,53 @@
|
||||
# references(account-manager)
|
||||
---
|
||||
description: "通用 Credential & Browser Profile Manager:管理网页账号、RPA 浏览器 profile、LLM/API 平台 API Key。支持物流、LLM、内容三大业务域。"
|
||||
---
|
||||
|
||||
供 Agent 按需加载的说明文档(与 `SKILL.md` 配合,渐进式披露)。
|
||||
# 🔐 账号与凭据管理
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| [CLI.md](CLI.md) | 子命令、参数、行为说明 |
|
||||
| [PLATFORMS.md](PLATFORMS.md) | 平台英文键、中文名、别名 |
|
||||
| [RUNTIME.md](RUNTIME.md) | 环境变量、数据路径、日志、登录调参 |
|
||||
| [ERRORS.md](ERRORS.md) | `ERROR:` / `OK:` 前缀一览 |
|
||||
| [INTEGRATION.md](INTEGRATION.md) | 跨 Skill 调用与 JSON 解析约定 |
|
||||
把你在各平台的账号与凭据**登记到本地**,后续物流 RPA、大模型写作、内容发布等技能才能自动选用对应环境。
|
||||
|
||||
示例 JSON 与 Schema 见仓库 `assets/`。
|
||||
> 本技能是**唯一**账号/凭据管理入口。业务 skill 不自己存密码、API Key,而是通过 CLI 调用获取。
|
||||
|
||||
## 它能帮你做什么
|
||||
|
||||
- **添加网页/RPA 账号** — 选平台,给出登录标识(邮箱/手机/用户名),自动创建浏览器 profile 目录。
|
||||
- **添加 API Key/Token** — 安全存储:API Key 明文存入 Windows Credential Manager 或环境变量引用,SQLite 只保存元数据和打码值。
|
||||
- **查看/列出账号** — 按平台、环境、角色等多维度筛选。
|
||||
- **给 RPA 技能用** — `pick-web` 按规则挑候选账号,支持 lease 防并发。
|
||||
- **给 LLM 技能用** — `credential pick` 安全获取 API Key。
|
||||
- **在浏览器里查看** — `open <id>` 打开 Chrome/Edge 查看,仅查看不写库。
|
||||
|
||||
## 你可以这样对它说
|
||||
|
||||
- 「帮我添加一个 Maersk 模拟器账号,邮箱 demo@maersk.com。」
|
||||
- 「添加 DeepSeek 的 API Key,存在 Windows 凭据管理器里。」
|
||||
- 「列出所有物流平台的账号。」
|
||||
- 「用浏览器打开 3 号账号看看页面。」
|
||||
- 「给 RPA 技能取一个 maersk 模拟器的 booking 账号。」
|
||||
|
||||
## 你需要提供什么
|
||||
|
||||
| 场景 | 你需要给的 |
|
||||
|---|---|
|
||||
| 添加网页账号 | **平台名称** + **登录标识**(邮箱/手机/用户名) |
|
||||
| 添加 API Key | **平台名称** + **凭据类型**(api_key 等)+ **secret**(通过 stdin 或环境变量) |
|
||||
| 查看/列出 | 可选:平台名、环境、角色等筛选条件 |
|
||||
| 浏览器打开 | 账号 **id**(需本机已安装 Chrome 或 Edge) |
|
||||
|
||||
## 它不做什么
|
||||
|
||||
- 不在此处判定各网站「是否已登录」(由业务技能自行处理)
|
||||
- 不代替内容管理、发布、大模型调用等业务技能
|
||||
- 不保存 API Key 明文在 SQLite 中(仅存引用和打码值)
|
||||
|
||||
## Secret 存储方式
|
||||
|
||||
| 方式 | 说明 |
|
||||
|---|---|
|
||||
| `none` | 无 secret(浏览器 profile 登录态) |
|
||||
| `env` | 引用环境变量名,运行时读取 |
|
||||
| `windows_credential` | 正式推荐:真实 secret 存 Windows Credential Manager |
|
||||
|
||||
---
|
||||
|
||||
**作者**:深圳匠厂科技有限公司 · **版本**:1.0.55
|
||||
|
||||
@@ -1,54 +1,59 @@
|
||||
# 运行环境与数据路径(account-manager)
|
||||
|
||||
## 数据落盘位置(当前实现)
|
||||
## 数据落盘位置
|
||||
|
||||
代码见 `scripts/util/runtime_paths.py`、`scripts/util/constants.py`。
|
||||
`get_data_root()` / `get_user_id()` 来自 **`scripts/jiangchang_skill_core/runtime_env.py`**;`scripts/util/runtime_paths.py` 在此基础上拼技能子目录。
|
||||
|
||||
- **数据根目录** `get_data_root()`:优先环境变量 **`JIANGCHANG_DATA_ROOT`**;未设置时 Windows 默认 `D:\jiangchang-data`,其它系统默认 `~/.jiangchang-data`。
|
||||
- **用户/工作空间 id** `get_user_id()`:优先 **`JIANGCHANG_USER_ID`**;未设置时为 **`_anon`**。
|
||||
- **数据根目录**:优先 **`JIANGCHANG_DATA_ROOT`**;未设置且未走本地注入时,Windows 默认 `D:\jiangchang-data`,其它系统 `~/.jiangchang-data`。
|
||||
- **用户/工作空间 id**:优先 **`JIANGCHANG_USER_ID`**;未设置时为 **`_anon`**。
|
||||
- **技能数据目录**:`{数据根}/{用户id}/account-manager/`
|
||||
- **SQLite**:`account-manager.db`
|
||||
- **Profile**:`profiles/<平台展示名>/<手机号>/`(由 `get_default_profile_dir` 生成)
|
||||
- **日志目录**:`logs/`(见下)
|
||||
|
||||
> OpenClaw 工作区文档中的 **`CLAW_*`** 变量:本技能**当前仍以 `JIANGCHANG_*` 为准**读取数据根与用户 id;若宿主已统一注入 `CLAW_*`,需在代码层做别名兼容(未做前请只依赖本文列出的变量)。
|
||||
- **Profile**:`profiles/{domain}/{platform_key}/{safe_label}[_{login_id_suffix}]/`
|
||||
- **日志**:与其它技能共用 `{数据根}/{用户id}/logs/jiangchang.log`
|
||||
|
||||
## 调试:为什么「list 没数据」
|
||||
|
||||
- 本地终端未设置 `JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID` 时,会落到 **默认盘路径** 与 **`_anon`**,与网关中运行的实例**不是同一个库文件**。
|
||||
- 设置 **`JIANGCHANG_ACCOUNT_DEBUG_PATHS=1`**(`1`/`true`/`yes`/`on`)后,每次 CLI 子命令前会在 **stderr** 打印实际使用的根目录、用户 id 与数据库路径(由 `_maybe_print_paths_debug_cli` 输出)。
|
||||
- 若终端与网关注入的 `JIANGCHANG_*` 不一致,会写到**不同用户目录**。
|
||||
- 设置 **`JIANGCHANG_ACCOUNT_DEBUG_PATHS=1`** 后,每次 CLI 命令前在 **stderr** 打印实际路径。
|
||||
|
||||
## 本地 CLI 开发注入(慎用)
|
||||
## 本地 CLI 默认值
|
||||
|
||||
`util/constants.py` 中 **`_apply_cli_local_dev_env()`** 仅在 CLI 入口调用:
|
||||
各技能 `main.py` 调用 `apply_cli_local_defaults()`:
|
||||
- `CLI_LOCAL_DEV_ENABLED = True` 时,未设置环境变量则注入平台默认值。
|
||||
- 发版前改为 `False` 或依赖宿主注入。
|
||||
|
||||
- 若开启本地开发开关(代码内 `_ACCOUNT_MANAGER_CLI_LOCAL_DEV` 或环境变量 **`JIANGCHANG_ACCOUNT_CLI_LOCAL_DEV=1`**),且当前未设置 `JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`,会注入代码内配置的默认路径与用户 id。
|
||||
- **发版前务必关闭**,避免个人路径进入制品。详见该文件内注释。
|
||||
## Secret 存储
|
||||
|
||||
| 方式 | 说明 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| `none` | 不存 secret | 浏览器 profile 登录态 |
|
||||
| `env` | SQLite 只存环境变量名,运行时从 `os.environ` 读取 | 轻量测试 |
|
||||
| `windows_credential` | 真实 secret 存 Windows Credential Manager | Windows 本地正式部署 |
|
||||
|
||||
- **不把 secret 明文写入 SQLite**
|
||||
- **不在日志中记录 secret 明文**
|
||||
- 只有 `credential pick --reveal` 或 `credential resolve --reveal` 才返回 secret
|
||||
|
||||
## 日志
|
||||
|
||||
`scripts/util/logging_config.py`:
|
||||
|
||||
| 变量 | 作用 |
|
||||
|------|------|
|
||||
| `JIANGCHANG_LOG_LEVEL` | 日志级别,默认 `INFO` |
|
||||
| `JIANGCHANG_LOG_TO_STDERR` | 设为 `1`/`true` 等时,WARNING 及以上同步 stderr |
|
||||
| `JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE` | 覆盖日志文件绝对路径(默认 `{技能数据目录}/logs/account-manager.log`) |
|
||||
| `JIANGCHANG_LOG_FILE` | 覆盖主日志文件绝对路径 |
|
||||
| `JIANGCHANG_LOG_BACKUP_COUNT` | 轮转保留份数,默认 `30` |
|
||||
| `JIANGCHANG_TRACE_ID` | 可选;跨技能子进程由父进程注入 |
|
||||
|
||||
## 登录 / 浏览器子进程(login)
|
||||
## 浏览器(open)
|
||||
|
||||
`service/browser_service.py` 与子进程 `service/login_child_runner.py`:
|
||||
- 本机已安装 Chrome 或 Edge
|
||||
- Python 环境需 `playwright` 包(匠厂共享 runtime 已包含)
|
||||
- 使用 **channel 模式**(`channel=chrome|msedge`),**不下载 Chromium**
|
||||
- **不要执行 `playwright install chromium`**
|
||||
|
||||
| 变量 | 作用 |
|
||||
|------|------|
|
||||
| `JIANGCHANG_LOGIN_TIMEOUT_SECONDS` | 登录检测最长秒数,默认 `300`,不少于 `60` |
|
||||
| `JIANGCHANG_LOGIN_POLL_INTERVAL_SECONDS` | DOM 轮询间隔,默认 `1.5`,限制在 `0.5..10` |
|
||||
| `JIANGCHANG_LOGIN_DOM_GRACE_SECONDS` | 打开 URL 后等待再检测,默认 `4`,减轻跳转误判 |
|
||||
### 关键浏览器参数
|
||||
|
||||
依赖:**本机已安装 Chrome 或 Edge**;Python 环境需 **`playwright`** 且已执行 `playwright install chromium`(错误信息见 CLI 输出)。
|
||||
|
||||
## 常见问题(简要)
|
||||
|
||||
- **`ERROR:需要 playwright`**:安装 `playwright` 并安装浏览器内核。
|
||||
- **`ERROR:PROFILE_DIR_MISSING`**:库中该账号 `profile_dir` 为空,需检查数据或重新添加账号流程。
|
||||
- **登录检测一直失败**:关闭占用同一 profile 目录的其它浏览器;可调高超时与环境变量;查看技能日志文件。
|
||||
- `headless=False`
|
||||
- `no_viewport=True`
|
||||
- `args` 包含 `--start-maximized`
|
||||
- stealth 模式可选(`OPENCLAW_PLAYWRIGHT_STEALTH` 环境变量控制)
|
||||
|
||||
122
references/SCHEMA.md
Normal file
122
references/SCHEMA.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# 数据库表结构(account-manager v2)
|
||||
|
||||
## 数据库路径
|
||||
|
||||
`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/account-manager/account-manager.db`
|
||||
|
||||
路径解析细节见 [RUNTIME.md](RUNTIME.md)。
|
||||
|
||||
## Schema 版本
|
||||
|
||||
`PRAGMA user_version = 2`
|
||||
|
||||
## 1. platforms — 平台注册表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `platform_key` | TEXT PK | 平台唯一键(maersk / deepseek / sohu 等) |
|
||||
| `display_name` | TEXT NOT NULL | 展示名(Maersk / DeepSeek / 搜狐号) |
|
||||
| `domain` | TEXT NOT NULL | 业务域:logistics / llm / content / ecommerce / generic |
|
||||
| `provider_code` | TEXT | 供应商代码,物流 profile 可复用 |
|
||||
| `default_url` | TEXT | 默认入口 URL |
|
||||
| `aliases_json` | TEXT | 别名 JSON 数组 |
|
||||
| `capabilities_json` | TEXT | 能力声明 JSON(`{"web":true,"api_key":true,"rpa":true}`) |
|
||||
| `enabled` | INTEGER | 是否启用(1=yes 0=no) |
|
||||
| `created_at` | INTEGER | Unix 秒 UTC |
|
||||
| `updated_at` | INTEGER | Unix 秒 UTC |
|
||||
|
||||
**索引:** `idx_platforms_domain`, `idx_platforms_provider_code`
|
||||
|
||||
## 2. accounts — 账号身份
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER PK AUTO | 主键 |
|
||||
| `platform_key` | TEXT NOT NULL | 对应 platforms.platform_key |
|
||||
| `account_label` | TEXT NOT NULL | 人可读名称 |
|
||||
| `login_id` | TEXT | 登录标识(email/phone/username/customer_code),可空 |
|
||||
| `login_id_type` | TEXT | email / username / phone / customer_code / api_account / unknown |
|
||||
| `tenant_id` | TEXT | 租户/项目标识,可空 |
|
||||
| `environment` | TEXT | simulator / staging / production / test |
|
||||
| `role` | TEXT | booking / tracking / admin / sales / api / default |
|
||||
| `provider_code` | TEXT | 冗余供应商码 |
|
||||
| `profile_dir` | TEXT | Playwright 持久化浏览器 profile 目录 |
|
||||
| `url` | TEXT | 账号默认入口 URL |
|
||||
| `status` | TEXT | active / disabled / needs_review |
|
||||
| `session_status` | TEXT | unknown / likely_valid / needs_login / expired |
|
||||
| `last_used_at` | INTEGER | 最近 pick/使用时间 |
|
||||
| `last_login_check_at` | INTEGER | 最近登录检查时间 |
|
||||
| `last_error_code` | TEXT | 最近错误代码 |
|
||||
| `last_error_message` | TEXT | 最近错误消息 |
|
||||
| `extra_json` | TEXT | 扩展字段 JSON |
|
||||
| `created_at` | INTEGER | Unix 秒 UTC |
|
||||
| `updated_at` | INTEGER | Unix 秒 UTC |
|
||||
|
||||
**索引:** `idx_accounts_platform`, `idx_accounts_pick_web`, `idx_accounts_provider`, `idx_accounts_status`
|
||||
|
||||
**唯一约束说明:** `(platform_key, login_id, tenant_id, environment, role)` 通过应用层检查,因为 SQLite 允许多条 NULL login_id。
|
||||
|
||||
## 3. credentials — 凭据(API Key / Token / Password)
|
||||
|
||||
**核心原则:SQLite 只保存元数据,不保存 secret 明文。**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER PK AUTO | 主键 |
|
||||
| `account_id` | INTEGER | 关联 accounts.id,可空(平台级 API Key) |
|
||||
| `platform_key` | TEXT NOT NULL | 平台唯一键 |
|
||||
| `credential_label` | TEXT NOT NULL | 人可读名称 |
|
||||
| `credential_type` | TEXT | api_key / password / bearer_token / oauth_client / refresh_token / browser_profile / note |
|
||||
| `secret_storage` | TEXT | none / env / windows_credential |
|
||||
| `secret_ref` | TEXT | env 时为环境变量名;windows_credential 时为 target name |
|
||||
| `secret_mask` | TEXT | 打码展示(sk-****abcd),不可反推 |
|
||||
| `expires_at` | INTEGER | 过期时间 |
|
||||
| `status` | TEXT | active / disabled / expired / needs_rotation |
|
||||
| `last_used_at` | INTEGER | 最近使用时间 |
|
||||
| `extra_json` | TEXT | 扩展字段 JSON |
|
||||
| `created_at` | INTEGER | Unix 秒 UTC |
|
||||
| `updated_at` | INTEGER | Unix 秒 UTC |
|
||||
|
||||
**索引:** `idx_credentials_platform_type`, `idx_credentials_account`, `idx_credentials_status`
|
||||
|
||||
## 4. account_leases — RPA 并发锁
|
||||
|
||||
防止多个任务同时打开同一个 profile_dir。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER PK AUTO | 主键 |
|
||||
| `account_id` | INTEGER NOT NULL | 关联 accounts.id |
|
||||
| `lease_token` | TEXT UNIQUE | 随机 token,用于 release/heartbeat |
|
||||
| `holder` | TEXT NOT NULL | 持有者标识 |
|
||||
| `purpose` | TEXT | 用途说明 |
|
||||
| `expires_at` | INTEGER NOT NULL | 过期时间 |
|
||||
| `heartbeat_at` | INTEGER | 最近心跳时间 |
|
||||
| `released_at` | INTEGER | 主动释放时间 |
|
||||
| `status` | TEXT | active / released / expired |
|
||||
| `created_at` | INTEGER | Unix 秒 UTC |
|
||||
| `updated_at` | INTEGER | Unix 秒 UTC |
|
||||
|
||||
**索引:** `idx_leases_account_status`, `idx_leases_token`, `idx_leases_expires`
|
||||
|
||||
**约束:**
|
||||
- acquire 前先把过期 active lease 标记为 expired
|
||||
- 同一 account_id 同时只能有一个 active 未过期 lease
|
||||
- release 必须校验 lease_token
|
||||
|
||||
## Profile 文件布局
|
||||
|
||||
```
|
||||
{DATA_ROOT}/{USER_ID}/account-manager/
|
||||
├── account-manager.db
|
||||
└── profiles/
|
||||
└── {domain}/
|
||||
└── {platform_key}/
|
||||
└── {safe_label}[_{login_id_suffix}]/
|
||||
```
|
||||
|
||||
删除账号时实现会尽量同时删除对应目录。
|
||||
|
||||
## 与 JSON 输出的对应关系
|
||||
|
||||
`account get/list/pick-web` 和 `credential pick` 的 JSON 输出字段与上表一一对应。详见 [INTEGRATION.md](INTEGRATION.md) 与 `assets/schemas/`。
|
||||
@@ -1,125 +1,265 @@
|
||||
"""CLI 入口:用法说明与 argv 分发。"""
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
CLI entry point: argv dispatch for account-manager v2.
|
||||
|
||||
Subcommand tree:
|
||||
platform list/get
|
||||
account add-web|add-secret|get|list|pick-web|mark-session|mark-error|mark-used
|
||||
credential pick|resolve
|
||||
lease release|list|cleanup
|
||||
(legacy) add|list|get|list-json|pick-web|open|delete
|
||||
|
||||
Machine commands output single-line JSON where appropriate.
|
||||
"""
|
||||
import sys
|
||||
|
||||
from service.account_service import (
|
||||
cmd_add,
|
||||
cmd_account_add_secret,
|
||||
cmd_account_add_web,
|
||||
cmd_account_get,
|
||||
cmd_account_list,
|
||||
cmd_account_mark_error,
|
||||
cmd_account_mark_session,
|
||||
cmd_account_mark_used,
|
||||
cmd_account_pick_web,
|
||||
cmd_credential_pick,
|
||||
cmd_credential_resolve,
|
||||
cmd_delete_by_id,
|
||||
cmd_delete_by_platform,
|
||||
cmd_delete_by_platform_phone,
|
||||
cmd_get,
|
||||
cmd_lease_cleanup,
|
||||
cmd_lease_list,
|
||||
cmd_lease_release,
|
||||
cmd_list,
|
||||
cmd_list_json,
|
||||
cmd_pick_logged_in,
|
||||
cmd_pick_web,
|
||||
cmd_set_login_status,
|
||||
cmd_platform_get,
|
||||
cmd_platform_list,
|
||||
)
|
||||
from service.browser_service import cmd_login, cmd_open
|
||||
from util.constants import _apply_cli_local_dev_env
|
||||
from util.logging_config import get_skill_logger
|
||||
from service.browser_service import cmd_open
|
||||
from util.constants import LOG_LOGGER_NAME, SKILL_SLUG
|
||||
from util.logging_config import get_skill_logger, setup_skill_logging
|
||||
from util.platforms import _platform_list_cn_for_help, resolve_platform_key
|
||||
from util.runtime_paths import _maybe_print_paths_debug_cli
|
||||
|
||||
# =========================================================================
|
||||
# Help text
|
||||
# =========================================================================
|
||||
|
||||
def _cli_print_full_usage() -> None:
|
||||
"""总览(未带子命令或需要完整说明时)。"""
|
||||
print("用法概览(将 main.py 换为你的路径):")
|
||||
print(" python main.py add <平台中文名> <手机号>")
|
||||
print(" python main.py list [平台|all|全部]")
|
||||
print(" python main.py pick-logged-in <平台> # 跨技能用:仅已登录账号,一行 JSON 或 ERROR")
|
||||
print(" python main.py pick-web <平台> # 跨技能用:优先已登录,否则最新一条账号")
|
||||
print(" python main.py list-json <平台|all> [--limit N] # 跨技能用:一行 JSON 数组,元素同 get")
|
||||
print(" python main.py get <id>")
|
||||
print(" python main.py set-login-status <id> <0|1> # 跨技能回写登录态")
|
||||
print(" python main.py open <id>")
|
||||
print(" python main.py login <id>")
|
||||
print(" python main.py delete id <id>")
|
||||
print(" python main.py delete platform <平台>")
|
||||
print(" python main.py delete platform <平台> <手机号>")
|
||||
print(" python main.py <平台> # 等价于只列该平台")
|
||||
print(" python main.py <平台> list # 同上")
|
||||
print()
|
||||
print("支持的平台(可用下列中文称呼,亦支持英文键如 sohu):")
|
||||
print(" " + _platform_list_cn_for_help())
|
||||
_USAGE = """account-manager v2 — Credential & Browser Profile Manager
|
||||
|
||||
平台管理:
|
||||
platform list [--domain logistics|llm|content]
|
||||
platform get <platform>
|
||||
|
||||
账号管理:
|
||||
account add-web --platform <p> --login-id <id> --login-id-type <type>
|
||||
[--label <label>] [--tenant-id <t>] [--environment <env>]
|
||||
[--role <role>] [--provider-code <code>] [--url <url>]
|
||||
[--extra-json <json>] [--profile-dir <path>]
|
||||
account add-secret --platform <p> --credential-type <type>
|
||||
--secret-storage env|windows_credential|none
|
||||
[--secret-ref <env_var>] [--secret-stdin]
|
||||
[--label <label>] [--account-id <id>]
|
||||
[--environment <env>] [--role <role>]
|
||||
account get <id> [--with-credentials]
|
||||
account list [--platform <p>] [--environment <env>]
|
||||
[--tenant-id <t>] [--role <role>] [--limit N]
|
||||
account pick-web --platform <p> [--environment <env>] [--tenant-id <t>]
|
||||
[--role <role>] [--lease] [--ttl-sec 900]
|
||||
[--holder <holder>] [--purpose <purpose>]
|
||||
account mark-session <id> --session-status <status>
|
||||
account mark-error <id> --code <code> --message <msg>
|
||||
account mark-used <id>
|
||||
account open <id>
|
||||
|
||||
凭据管理:
|
||||
credential pick --platform <p> [--type <type>] [--environment <env>]
|
||||
[--role <role>] [--reveal]
|
||||
credential resolve --id <credential_id> [--reveal]
|
||||
|
||||
租约管理:
|
||||
lease release <lease_token>
|
||||
lease list [--active]
|
||||
lease cleanup
|
||||
|
||||
浏览器:
|
||||
account open <id>
|
||||
open <id> # 兼容别名
|
||||
|
||||
兼容入口(旧版):
|
||||
get <id>
|
||||
list-json <platform|all> [--limit N]
|
||||
pick-web <platform>
|
||||
list [platform|all]
|
||||
add <platform> <phone>
|
||||
delete id|platform <...>
|
||||
"""
|
||||
|
||||
|
||||
def _cli_fail_add() -> None:
|
||||
print("ERROR:CLI_ADD_MISSING_ARGS")
|
||||
print()
|
||||
print("【add 添加账号】参数不足。")
|
||||
print(" 必填:平台名称(中文即可,如 搜狐号、知乎、微信公众号)")
|
||||
print(" 必填:中国大陆 11 位手机号(1 开头,第二位 3~9),同平台下不可重复")
|
||||
print()
|
||||
print("示例:")
|
||||
print(" python main.py add 搜狐号 13800138000")
|
||||
print(" python main.py add 知乎 13900001111")
|
||||
print()
|
||||
print("支持的平台:" + _platform_list_cn_for_help())
|
||||
# =========================================================================
|
||||
# Argument parsing helpers
|
||||
# =========================================================================
|
||||
|
||||
def _get_opt(argv: list[str], flag: str, default=None):
|
||||
"""Get value of --flag from argv, returning default if not found."""
|
||||
if flag in argv:
|
||||
idx = argv.index(flag)
|
||||
if idx + 1 < len(argv):
|
||||
return argv[idx + 1]
|
||||
return default
|
||||
|
||||
|
||||
def _cli_fail_need_account_id(subcmd: str) -> None:
|
||||
print(f"ERROR:CLI_{subcmd.upper()}_MISSING_ID")
|
||||
print()
|
||||
print(f"【{subcmd}】缺少必填参数:账号 id(数据库自增整数,可先 list 查看)。")
|
||||
print("示例:")
|
||||
print(f" python main.py {subcmd} 1")
|
||||
def _has_flag(argv: list[str], flag: str) -> bool:
|
||||
return flag in argv
|
||||
|
||||
|
||||
def _cli_fail_delete() -> None:
|
||||
print("ERROR:CLI_DELETE_MISSING_ARGS")
|
||||
print()
|
||||
print("【delete 删除账号】参数不足。支持三种方式:")
|
||||
print(" 按 id:python main.py delete id <id>")
|
||||
print(" 按平台(删该平台下全部):python main.py delete platform <平台中文名或英文键>")
|
||||
print(" 按平台+手机号:python main.py delete platform <平台> <手机号>")
|
||||
print()
|
||||
print("示例:")
|
||||
print(" python main.py delete id 3")
|
||||
print(" python main.py delete platform 搜狐号")
|
||||
print(" python main.py delete platform 头条号 18925203701")
|
||||
print()
|
||||
print("说明:会同时删除数据库记录与 profile 用户数据目录(若存在)。")
|
||||
def _get_int_opt(argv: list[str], flag: str, default: int) -> int:
|
||||
v = _get_opt(argv, flag)
|
||||
if v is not None:
|
||||
try:
|
||||
return int(v)
|
||||
except ValueError:
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Main dispatch
|
||||
# =========================================================================
|
||||
|
||||
def main() -> None:
|
||||
setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME)
|
||||
log = get_skill_logger()
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
_cli_print_full_usage()
|
||||
print(_USAGE)
|
||||
sys.exit(1)
|
||||
|
||||
_apply_cli_local_dev_env()
|
||||
get_skill_logger().info("cli_start argv=%s", sys.argv)
|
||||
cmd = sys.argv[1]
|
||||
log.info("cli_start argv=%s", sys.argv)
|
||||
av = sys.argv
|
||||
cmd = av[1]
|
||||
_maybe_print_paths_debug_cli()
|
||||
|
||||
# Determine if first arg is a platform key (for legacy shorthand)
|
||||
plat_from_first = resolve_platform_key(cmd)
|
||||
|
||||
if len(sys.argv) >= 3 and sys.argv[2] == "list" and plat_from_first:
|
||||
cmd_list(plat_from_first)
|
||||
elif plat_from_first and len(sys.argv) == 2:
|
||||
cmd_list(plat_from_first)
|
||||
elif cmd == "add":
|
||||
if len(sys.argv) < 4:
|
||||
_cli_fail_add()
|
||||
# ---- platform subcommand ----
|
||||
if cmd == "platform":
|
||||
if len(av) < 3:
|
||||
print("ERROR:CLI_BAD_ARGS platform 需要子命令:list | get")
|
||||
sys.exit(1)
|
||||
cmd_add(sys.argv[2], sys.argv[3])
|
||||
elif cmd == "list":
|
||||
cmd_list(sys.argv[2] if len(sys.argv) >= 3 else "all")
|
||||
sub = av[2]
|
||||
if sub == "list":
|
||||
domain = _get_opt(av, "--domain")
|
||||
cmd_platform_list(domain)
|
||||
elif sub == "get":
|
||||
if len(av) < 4:
|
||||
print("ERROR:CLI_BAD_ARGS platform get 需要 <platform>。")
|
||||
sys.exit(1)
|
||||
cmd_platform_get(av[3])
|
||||
else:
|
||||
print("ERROR:CLI_BAD_ARGS platform 子命令必须是 list 或 get。")
|
||||
sys.exit(1)
|
||||
|
||||
# ---- account subcommand ----
|
||||
elif cmd == "account":
|
||||
if len(av) < 3:
|
||||
print("ERROR:CLI_BAD_ARGS account 需要子命令(add-web / get / …)。")
|
||||
print(_USAGE)
|
||||
sys.exit(1)
|
||||
sub = av[2]
|
||||
if sub == "add-web":
|
||||
_cmd_add_web(av)
|
||||
elif sub == "add-secret":
|
||||
_cmd_add_secret(av)
|
||||
elif sub == "get":
|
||||
if len(av) < 4:
|
||||
print("ERROR:CLI_BAD_ARGS account get 需要 <id>。")
|
||||
sys.exit(1)
|
||||
aid = av[3]
|
||||
with_creds = _has_flag(av, "--with-credentials")
|
||||
cmd_account_get(aid, include_credentials=with_creds)
|
||||
elif sub == "list":
|
||||
_cmd_account_list(av)
|
||||
elif sub == "pick-web":
|
||||
_cmd_pick_web_new(av)
|
||||
elif sub == "mark-session":
|
||||
if len(av) < 4:
|
||||
print("ERROR:CLI_BAD_ARGS account mark-session 需要 <id>。")
|
||||
sys.exit(1)
|
||||
aid = av[3]
|
||||
ss = _get_opt(av, "--session-status", "unknown")
|
||||
cmd_account_mark_session(aid, ss)
|
||||
elif sub == "mark-error":
|
||||
if len(av) < 4:
|
||||
print("ERROR:CLI_BAD_ARGS account mark-error 需要 <id>。")
|
||||
sys.exit(1)
|
||||
aid = av[3]
|
||||
ec = _get_opt(av, "--code", "")
|
||||
em = _get_opt(av, "--message", "")
|
||||
cmd_account_mark_error(aid, ec, em)
|
||||
elif sub == "mark-used":
|
||||
if len(av) < 4:
|
||||
print("ERROR:CLI_BAD_ARGS account mark-used 需要 <id>。")
|
||||
sys.exit(1)
|
||||
aid = av[3]
|
||||
cmd_account_mark_used(aid)
|
||||
elif sub == "open":
|
||||
if len(av) < 4:
|
||||
print("ERROR:CLI_BAD_ARGS account open 需要 <id>。")
|
||||
sys.exit(1)
|
||||
cmd_open(av[3])
|
||||
else:
|
||||
print("ERROR:CLI_BAD_ARGS 未知的 account 子命令。")
|
||||
print(_USAGE)
|
||||
sys.exit(1)
|
||||
|
||||
# ---- credential subcommand ----
|
||||
elif cmd == "credential":
|
||||
if len(av) < 3:
|
||||
print("ERROR:CLI_BAD_ARGS credential 需要子命令:pick | resolve")
|
||||
sys.exit(1)
|
||||
sub = av[2]
|
||||
if sub == "pick":
|
||||
_cmd_cred_pick(av)
|
||||
elif sub == "resolve":
|
||||
_cmd_cred_resolve(av)
|
||||
else:
|
||||
print("ERROR:CLI_BAD_ARGS credential 子命令必须是 pick 或 resolve。")
|
||||
sys.exit(1)
|
||||
|
||||
# ---- lease subcommand ----
|
||||
elif cmd == "lease":
|
||||
if len(av) < 3:
|
||||
print("ERROR:CLI_BAD_ARGS lease 需要子命令:release | list | cleanup")
|
||||
sys.exit(1)
|
||||
sub = av[2]
|
||||
if sub == "release":
|
||||
if len(av) < 4:
|
||||
print("ERROR:CLI_BAD_ARGS lease release 需要 <lease_token>。")
|
||||
sys.exit(1)
|
||||
cmd_lease_release(av[3])
|
||||
elif sub == "list":
|
||||
active = _has_flag(av, "--active")
|
||||
cmd_lease_list(active)
|
||||
elif sub == "cleanup":
|
||||
cmd_lease_cleanup()
|
||||
else:
|
||||
print("ERROR:CLI_BAD_ARGS lease 子命令必须是 release、list 或 cleanup。")
|
||||
sys.exit(1)
|
||||
|
||||
# ---- Legacy aliases ----
|
||||
elif cmd == "get":
|
||||
if len(sys.argv) < 3:
|
||||
_cli_fail_need_account_id("get")
|
||||
print("ERROR:CLI_GET_MISSING_ID")
|
||||
sys.exit(1)
|
||||
cmd_get(sys.argv[2])
|
||||
elif cmd == "set-login-status":
|
||||
if len(sys.argv) < 4:
|
||||
print("ERROR:CLI_SET_LOGIN_STATUS_MISSING_ARGS")
|
||||
print("用法:python main.py set-login-status <account_id> <0|1>")
|
||||
print("说明:供 llm-manager 等脚本回写登录态;成功时 stdout 首行以 OK: 开头。")
|
||||
sys.exit(1)
|
||||
cmd_set_login_status(sys.argv[2], sys.argv[3])
|
||||
|
||||
elif cmd == "list-json":
|
||||
if len(sys.argv) < 3:
|
||||
print("ERROR:CLI_LIST_JSON_MISSING_PLATFORM")
|
||||
print("用法:python main.py list-json <平台|all|全部> [--limit N]")
|
||||
print("说明:跨技能编排;成功时 stdout 仅一行 JSON 数组(元素与 get 一致)。")
|
||||
sys.exit(1)
|
||||
av = sys.argv[2:]
|
||||
platform_arg = av[0]
|
||||
@@ -132,33 +272,45 @@ def main() -> None:
|
||||
except ValueError:
|
||||
pass
|
||||
cmd_list_json(platform_arg, limit=lim)
|
||||
elif cmd == "pick-logged-in":
|
||||
if len(sys.argv) < 3:
|
||||
print("ERROR:CLI_PICK_LOGGED_IN_MISSING_ARGS")
|
||||
print("用法:python main.py pick-logged-in <平台中文名或英文键>")
|
||||
print("说明:供 llm-manager 等技能查询该平台已登录账号;成功时 stdout 仅一行 JSON。")
|
||||
sys.exit(1)
|
||||
cmd_pick_logged_in(sys.argv[2])
|
||||
|
||||
elif cmd == "pick-web":
|
||||
if len(sys.argv) < 3:
|
||||
print("ERROR:CLI_PICK_WEB_MISSING_ARGS")
|
||||
print("用法:python main.py pick-web <平台中文名或英文键>")
|
||||
print("说明:供 llm-manager 等;优先已登录账号,否则返回该平台最新一条账号(未登录也可用于打开浏览器登录)。")
|
||||
sys.exit(1)
|
||||
cmd_pick_web(sys.argv[2])
|
||||
|
||||
elif cmd == "open":
|
||||
if len(sys.argv) < 3:
|
||||
_cli_fail_need_account_id("open")
|
||||
print("ERROR:CLI_OPEN_MISSING_ID")
|
||||
sys.exit(1)
|
||||
cmd_open(sys.argv[2])
|
||||
elif cmd == "login":
|
||||
if len(sys.argv) < 3:
|
||||
_cli_fail_need_account_id("login")
|
||||
|
||||
elif cmd == "list":
|
||||
platform_arg = sys.argv[2] if len(sys.argv) >= 3 else "all"
|
||||
cmd_list(platform_arg)
|
||||
|
||||
elif cmd == "add":
|
||||
if len(sys.argv) < 4:
|
||||
print("ERROR:CLI_ADD_MISSING_ARGS")
|
||||
sys.exit(1)
|
||||
cmd_login(sys.argv[2])
|
||||
# Legacy add maps to add-web for backward compat
|
||||
cmd_account_add_web(
|
||||
platform_input=sys.argv[2],
|
||||
login_id=sys.argv[3],
|
||||
login_id_type="phone",
|
||||
label=None,
|
||||
tenant_id=None,
|
||||
environment="production",
|
||||
role="default",
|
||||
provider_code=None,
|
||||
url=None,
|
||||
extra_json_str=None,
|
||||
profile_dir_override=None,
|
||||
)
|
||||
|
||||
elif cmd == "delete":
|
||||
if len(sys.argv) < 4:
|
||||
_cli_fail_delete()
|
||||
print("ERROR:CLI_DELETE_MISSING_ARGS")
|
||||
sys.exit(1)
|
||||
mode = (sys.argv[2] or "").strip().lower()
|
||||
if mode == "id":
|
||||
@@ -169,17 +321,151 @@ def main() -> None:
|
||||
else:
|
||||
cmd_delete_by_platform(sys.argv[3])
|
||||
else:
|
||||
print(f"ERROR:CLI_DELETE_BAD_MODE 不支持的删除方式「{sys.argv[2]}」,请使用 id 或 platform。")
|
||||
_cli_fail_delete()
|
||||
print(f"ERROR:CLI_DELETE_BAD_MODE 不支持的删除方式「{sys.argv[2]}」。")
|
||||
sys.exit(1)
|
||||
|
||||
# ---- Platform shorthand: "python main.py <platform>" ----
|
||||
elif plat_from_first:
|
||||
cmd_list(sys.argv[1])
|
||||
|
||||
# ---- Unknown ----
|
||||
else:
|
||||
print(f"ERROR:CLI_UNKNOWN_OR_BAD_ARGS 子命令「{cmd}」无法识别,或参数组合不合法。")
|
||||
print(f"ERROR:CLI_UNKNOWN_OR_BAD_ARGS 子命令「{cmd}」无法识别。")
|
||||
print()
|
||||
_cli_print_full_usage()
|
||||
print()
|
||||
print("说明:list 的筛选可为 all/全部,或上面列出的任一平台中文名。")
|
||||
print(_USAGE)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Sub-parsers for complex commands
|
||||
# =========================================================================
|
||||
|
||||
def _cmd_add_web(argv: list[str]) -> None:
|
||||
plat = _get_opt(argv, "--platform", "")
|
||||
login_id = _get_opt(argv, "--login-id")
|
||||
login_id_type = _get_opt(argv, "--login-id-type", "unknown")
|
||||
label = _get_opt(argv, "--label")
|
||||
tenant_id = _get_opt(argv, "--tenant-id")
|
||||
environment = _get_opt(argv, "--environment", "production")
|
||||
role = _get_opt(argv, "--role", "default")
|
||||
provider_code = _get_opt(argv, "--provider-code")
|
||||
url = _get_opt(argv, "--url")
|
||||
extra_json_str = _get_opt(argv, "--extra-json")
|
||||
profile_dir_override = _get_opt(argv, "--profile-dir")
|
||||
|
||||
if not plat:
|
||||
print("ERROR:CLI_BAD_ARGS account add-web 需要 --platform <platform>。")
|
||||
return
|
||||
cmd_account_add_web(
|
||||
platform_input=plat,
|
||||
login_id=login_id,
|
||||
login_id_type=login_id_type,
|
||||
label=label,
|
||||
tenant_id=tenant_id,
|
||||
environment=environment,
|
||||
role=role,
|
||||
provider_code=provider_code,
|
||||
url=url,
|
||||
extra_json_str=extra_json_str,
|
||||
profile_dir_override=profile_dir_override,
|
||||
)
|
||||
|
||||
|
||||
def _cmd_add_secret(argv: list[str]) -> None:
|
||||
plat = _get_opt(argv, "--platform", "")
|
||||
ctype = _get_opt(argv, "--credential-type", "api_key")
|
||||
label = _get_opt(argv, "--label")
|
||||
storage = _get_opt(argv, "--secret-storage", "env")
|
||||
stdin_mode = _has_flag(argv, "--secret-stdin")
|
||||
sref = _get_opt(argv, "--secret-ref")
|
||||
aid = _get_int_opt(argv, "--account-id", 0) or None
|
||||
env = _get_opt(argv, "--environment", "production")
|
||||
role = _get_opt(argv, "--role", "api")
|
||||
extra_json_str = _get_opt(argv, "--extra-json")
|
||||
|
||||
if not plat:
|
||||
print("ERROR:CLI_BAD_ARGS account add-secret 需要 --platform <platform>。")
|
||||
return
|
||||
cmd_account_add_secret(
|
||||
platform_input=plat,
|
||||
credential_type=ctype,
|
||||
label=label,
|
||||
secret_storage=storage,
|
||||
secret_stdin=stdin_mode,
|
||||
secret_ref=sref,
|
||||
account_id=aid,
|
||||
environment=env,
|
||||
role=role,
|
||||
extra_json_str=extra_json_str,
|
||||
)
|
||||
|
||||
|
||||
def _cmd_account_list(argv: list[str]) -> None:
|
||||
plat = _get_opt(argv, "--platform")
|
||||
env = _get_opt(argv, "--environment")
|
||||
tenant = _get_opt(argv, "--tenant-id")
|
||||
role = _get_opt(argv, "--role")
|
||||
limit = _get_int_opt(argv, "--limit", 200)
|
||||
cmd_account_list(
|
||||
platform_input=plat,
|
||||
environment=env,
|
||||
tenant_id=tenant,
|
||||
role=role,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
def _cmd_pick_web_new(argv: list[str]) -> None:
|
||||
plat = _get_opt(argv, "--platform", "")
|
||||
env = _get_opt(argv, "--environment")
|
||||
tenant = _get_opt(argv, "--tenant-id")
|
||||
role = _get_opt(argv, "--role")
|
||||
do_lease = _has_flag(argv, "--lease")
|
||||
ttl = _get_int_opt(argv, "--ttl-sec", 900)
|
||||
holder = _get_opt(argv, "--holder")
|
||||
purpose = _get_opt(argv, "--purpose")
|
||||
|
||||
if not plat:
|
||||
print("ERROR:CLI_BAD_ARGS account pick-web 需要 --platform <platform>。")
|
||||
return
|
||||
cmd_account_pick_web(
|
||||
platform_input=plat,
|
||||
environment=env,
|
||||
tenant_id=tenant,
|
||||
role=role,
|
||||
do_lease=do_lease,
|
||||
ttl_sec=ttl,
|
||||
holder=holder,
|
||||
purpose=purpose,
|
||||
)
|
||||
|
||||
|
||||
def _cmd_cred_pick(argv: list[str]) -> None:
|
||||
plat = _get_opt(argv, "--platform", "")
|
||||
ctype = _get_opt(argv, "--type")
|
||||
env = _get_opt(argv, "--environment")
|
||||
role = _get_opt(argv, "--role")
|
||||
reveal = _has_flag(argv, "--reveal")
|
||||
if not plat:
|
||||
print("ERROR:CLI_BAD_ARGS credential pick 需要 --platform <platform>。")
|
||||
return
|
||||
cmd_credential_pick(
|
||||
platform_input=plat,
|
||||
credential_type=ctype,
|
||||
environment=env,
|
||||
role=role,
|
||||
reveal=reveal,
|
||||
)
|
||||
|
||||
|
||||
def _cmd_cred_resolve(argv: list[str]) -> None:
|
||||
cid = _get_int_opt(argv, "--id", 0)
|
||||
reveal = _has_flag(argv, "--reveal")
|
||||
if cid <= 0:
|
||||
print("ERROR:CLI_BAD_ARGS credential resolve 需要 --id <credential_id>。")
|
||||
return
|
||||
cmd_credential_resolve(cid, reveal=reveal)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
"""Accounts table: CRUD and queries (no stdout)."""
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Accounts, Credentials, Leases, and Platforms CRUD (no stdout).
|
||||
|
||||
All functions operate on a connection (passed or opened locally).
|
||||
Time fields are INTEGER Unix seconds (UTC).
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from db.connection import get_conn, init_db
|
||||
from util.platforms import PLATFORM_URLS, _PLATFORM_NAME_CN
|
||||
from util.logging_config import get_skill_logger
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _now_unix() -> int:
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def _unix_to_iso_local(ts: Optional[int]) -> Optional[str]:
|
||||
"""对外 JSON:本地时区 ISO8601 字符串,便于人读。"""
|
||||
"""Convert Unix timestamp to local ISO8601 string, or None."""
|
||||
if ts is None:
|
||||
return None
|
||||
try:
|
||||
@@ -22,248 +32,636 @@ def _unix_to_iso_local(ts: Optional[int]) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def normalize_account_id(account_id):
|
||||
if account_id is None:
|
||||
return None
|
||||
s = str(account_id).strip()
|
||||
if s.isdigit():
|
||||
return int(s)
|
||||
return s
|
||||
def _acc_row_to_dict(row) -> dict[str, Any]:
|
||||
"""Convert an accounts table row tuple to a dict matching the v2 schema."""
|
||||
return {
|
||||
"id": row[0],
|
||||
"platform_key": row[1],
|
||||
"account_label": row[2],
|
||||
"login_id": row[3] or "",
|
||||
"login_id_type": row[4],
|
||||
"tenant_id": row[5] or "",
|
||||
"environment": row[6],
|
||||
"role": row[7],
|
||||
"provider_code": row[8] or "",
|
||||
"profile_dir": (row[9] or "").strip(),
|
||||
"url": row[10] or "",
|
||||
"status": row[11],
|
||||
"session_status": row[12],
|
||||
"last_used_at": _unix_to_iso_local(row[13]),
|
||||
"last_login_check_at": _unix_to_iso_local(row[14]),
|
||||
"last_error_code": row[15] or "",
|
||||
"last_error_message": row[16] or "",
|
||||
"extra_json": _safe_json_loads(row[17]),
|
||||
"created_at": _unix_to_iso_local(row[18]),
|
||||
"updated_at": _unix_to_iso_local(row[19]),
|
||||
}
|
||||
|
||||
|
||||
def default_name_for_platform(platform: str, index: int) -> str:
|
||||
cn = _PLATFORM_NAME_CN.get(platform)
|
||||
if cn:
|
||||
return f"{cn}{index}号"
|
||||
return f"{platform}_{index}"
|
||||
# ============================================================================
|
||||
# Platforms
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def get_account_by_id(account_id):
|
||||
init_db()
|
||||
aid = normalize_account_id(account_id)
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name, platform, phone, profile_dir, url, login_status, last_login_at, extra_json,
|
||||
created_at, updated_at
|
||||
FROM accounts WHERE id = ?
|
||||
""",
|
||||
(aid,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
acc: dict[str, Any] = {
|
||||
"id": row[0],
|
||||
"name": row[1],
|
||||
"platform": row[2],
|
||||
"phone": row[3] or "",
|
||||
"profile_dir": (row[4] or "").strip(),
|
||||
"url": row[5] or PLATFORM_URLS.get(row[2], ""),
|
||||
"login_status": int(row[6] or 0),
|
||||
"last_login_at": _unix_to_iso_local(row[7]),
|
||||
"created_at": _unix_to_iso_local(row[9]),
|
||||
"updated_at": _unix_to_iso_local(row[10]),
|
||||
}
|
||||
if row[8]:
|
||||
try:
|
||||
extra = json.loads(row[8])
|
||||
if isinstance(extra, dict):
|
||||
acc.update(extra)
|
||||
except Exception:
|
||||
pass
|
||||
return acc
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def mark_login_status(account_id, success: bool):
|
||||
now = _now_unix()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
if success:
|
||||
cur.execute(
|
||||
"UPDATE accounts SET login_status = 1, last_login_at = ?, updated_at = ? WHERE id = ?",
|
||||
(now, now, account_id),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"UPDATE accounts SET login_status = 0, updated_at = ? WHERE id = ?",
|
||||
(now, account_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def fetch_list_rows(platform_key: str, limit: int):
|
||||
"""platform_key 'all' or platform slug; returns list of row tuples (full row)."""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
sql = (
|
||||
"SELECT id, name, platform, phone, profile_dir, url, login_status, "
|
||||
"last_login_at, extra_json, created_at, updated_at FROM accounts "
|
||||
)
|
||||
if platform_key == "all":
|
||||
cur.execute(sql + "ORDER BY created_at DESC, id DESC LIMIT ?", (int(limit),))
|
||||
else:
|
||||
cur.execute(
|
||||
sql + "WHERE platform = ? ORDER BY created_at DESC, id DESC LIMIT ?",
|
||||
(platform_key, int(limit)),
|
||||
)
|
||||
return cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def fetch_ids_for_list_json(platform_key: str, lim: int):
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
if platform_key == "all":
|
||||
cur.execute(
|
||||
"SELECT id FROM accounts ORDER BY updated_at DESC, id DESC LIMIT ?",
|
||||
(lim,),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"SELECT id FROM accounts WHERE platform = ? "
|
||||
"ORDER BY updated_at DESC, id DESC LIMIT ?",
|
||||
(platform_key, lim),
|
||||
)
|
||||
return [r[0] for r in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def fetch_pick_logged_in_id(platform_key: str) -> Optional[int]:
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM accounts
|
||||
WHERE platform = ? AND login_status = 1
|
||||
ORDER BY (last_login_at IS NULL), last_login_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(platform_key,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return int(row[0]) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def fetch_pick_web_candidate_id(platform_key: str) -> Optional[int]:
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM accounts
|
||||
WHERE platform = ? AND login_status = 1
|
||||
ORDER BY (last_login_at IS NULL), last_login_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(platform_key,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM accounts
|
||||
WHERE platform = ?
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(platform_key,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return int(row[0]) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def has_duplicate_phone_conn(conn, platform_key: str, phone_store: str) -> bool:
|
||||
def platform_exists(conn, platform_key: str) -> bool:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT id FROM accounts WHERE platform = ? AND phone = ?",
|
||||
(platform_key, phone_store),
|
||||
)
|
||||
cur.execute("SELECT 1 FROM platforms WHERE platform_key = ?", (platform_key,))
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def count_platform_accounts_conn(conn, platform_key: str) -> int:
|
||||
def list_platforms_from_db(conn, domain: Optional[str] = None) -> list[dict]:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM accounts WHERE platform = ?", (platform_key,))
|
||||
return int(cur.fetchone()[0])
|
||||
if domain:
|
||||
cur.execute(
|
||||
"SELECT platform_key, display_name, domain, provider_code, "
|
||||
"default_url, aliases_json, capabilities_json, enabled, "
|
||||
"created_at, updated_at FROM platforms WHERE domain = ? ORDER BY platform_key",
|
||||
(domain,),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"SELECT platform_key, display_name, domain, provider_code, "
|
||||
"default_url, aliases_json, capabilities_json, enabled, "
|
||||
"created_at, updated_at FROM platforms ORDER BY platform_key"
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
result = []
|
||||
for r in rows:
|
||||
result.append({
|
||||
"platform_key": r[0],
|
||||
"display_name": r[1],
|
||||
"domain": r[2],
|
||||
"provider_code": r[3] or "",
|
||||
"default_url": r[4] or "",
|
||||
"aliases": _safe_json_loads(r[5], []),
|
||||
"capabilities": _safe_json_loads(r[6], {}),
|
||||
"enabled": bool(r[7]),
|
||||
"created_at": _unix_to_iso_local(r[8]),
|
||||
"updated_at": _unix_to_iso_local(r[9]),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def insert_account_row(
|
||||
def get_platform_from_db(conn, platform_key: str) -> Optional[dict]:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT platform_key, display_name, domain, provider_code, "
|
||||
"default_url, aliases_json, capabilities_json, enabled, "
|
||||
"created_at, updated_at FROM platforms WHERE platform_key = ?",
|
||||
(platform_key,),
|
||||
)
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
return None
|
||||
return {
|
||||
"platform_key": r[0],
|
||||
"display_name": r[1],
|
||||
"domain": r[2],
|
||||
"provider_code": r[3] or "",
|
||||
"default_url": r[4] or "",
|
||||
"aliases": _safe_json_loads(r[5], []),
|
||||
"capabilities": _safe_json_loads(r[6], {}),
|
||||
"enabled": bool(r[7]),
|
||||
"created_at": _unix_to_iso_local(r[8]),
|
||||
"updated_at": _unix_to_iso_local(r[9]),
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Accounts — CRUD
|
||||
# ============================================================================
|
||||
|
||||
def insert_account(
|
||||
conn,
|
||||
name: str,
|
||||
platform_key: str,
|
||||
phone_store: str,
|
||||
profile_dir: str,
|
||||
url: str,
|
||||
account_label: str,
|
||||
login_id: Optional[str],
|
||||
login_id_type: str,
|
||||
tenant_id: Optional[str],
|
||||
environment: str,
|
||||
role: str,
|
||||
provider_code: Optional[str],
|
||||
profile_dir: Optional[str],
|
||||
url: Optional[str],
|
||||
extra_json: Optional[str],
|
||||
now: int,
|
||||
) -> int:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO accounts (name, platform, phone, profile_dir, url, login_status, last_login_at, extra_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, NULL, NULL, ?, ?)
|
||||
INSERT INTO accounts
|
||||
(platform_key, account_label, login_id, login_id_type, tenant_id,
|
||||
environment, role, provider_code, profile_dir, url,
|
||||
status, session_status, extra_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 'unknown', ?, ?, ?)
|
||||
""",
|
||||
(name, platform_key, phone_store, profile_dir, url, now, now),
|
||||
(
|
||||
platform_key, account_label, login_id, login_id_type, tenant_id,
|
||||
environment, role, provider_code, profile_dir, url,
|
||||
extra_json or "{}", now, now,
|
||||
),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def fetch_delete_row_by_id_conn(conn, aid: int):
|
||||
def get_account_by_id(account_id) -> Optional[dict]:
|
||||
"""Get a single account by id. Returns dict or None."""
|
||||
conn = get_conn()
|
||||
try:
|
||||
conn.row_factory = None
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, platform_key, account_label, login_id, login_id_type,
|
||||
tenant_id, environment, role, provider_code,
|
||||
profile_dir, url, status, session_status,
|
||||
last_used_at, last_login_check_at,
|
||||
last_error_code, last_error_message, extra_json,
|
||||
created_at, updated_at
|
||||
FROM accounts WHERE id = ?
|
||||
""",
|
||||
(int(account_id),),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return _acc_row_to_dict(row)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def find_accounts(
|
||||
platform_key: Optional[str] = None,
|
||||
environment: Optional[str] = None,
|
||||
tenant_id: Optional[str] = None,
|
||||
role: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
provider_code: Optional[str] = None,
|
||||
limit: int = 200,
|
||||
) -> list[dict]:
|
||||
"""Find accounts by optional filters. Returns list of dicts."""
|
||||
conn = get_conn()
|
||||
try:
|
||||
conn.row_factory = None
|
||||
cur = conn.cursor()
|
||||
conditions = []
|
||||
params = []
|
||||
if platform_key:
|
||||
conditions.append("platform_key = ?")
|
||||
params.append(platform_key)
|
||||
if environment:
|
||||
conditions.append("environment = ?")
|
||||
params.append(environment)
|
||||
if tenant_id:
|
||||
conditions.append("tenant_id = ?")
|
||||
params.append(tenant_id)
|
||||
if role:
|
||||
conditions.append("role = ?")
|
||||
params.append(role)
|
||||
if status:
|
||||
conditions.append("status = ?")
|
||||
params.append(status)
|
||||
if provider_code:
|
||||
conditions.append("provider_code = ?")
|
||||
params.append(provider_code)
|
||||
|
||||
where = ""
|
||||
if conditions:
|
||||
where = "WHERE " + " AND ".join(conditions)
|
||||
|
||||
sql = (
|
||||
"SELECT id, platform_key, account_label, login_id, login_id_type, "
|
||||
"tenant_id, environment, role, provider_code, profile_dir, url, "
|
||||
"status, session_status, last_used_at, last_login_check_at, "
|
||||
"last_error_code, last_error_message, extra_json, created_at, updated_at "
|
||||
f"FROM accounts {where} ORDER BY updated_at DESC, id DESC LIMIT ?"
|
||||
)
|
||||
params.append(int(limit))
|
||||
cur.execute(sql, params)
|
||||
return [_acc_row_to_dict(row) for row in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def find_account_ids(
|
||||
platform_key: str,
|
||||
environment: Optional[str] = None,
|
||||
tenant_id: Optional[str] = None,
|
||||
role: Optional[str] = None,
|
||||
) -> list[int]:
|
||||
"""Return account IDs matching filters, excluding those with active leases."""
|
||||
conn = get_conn()
|
||||
try:
|
||||
conn.row_factory = None
|
||||
cur = conn.cursor()
|
||||
conditions = ["a.status = 'active'", "a.platform_key = ?"]
|
||||
params = [platform_key]
|
||||
if environment:
|
||||
conditions.append("a.environment = ?")
|
||||
params.append(environment)
|
||||
if tenant_id:
|
||||
conditions.append("a.tenant_id = ?")
|
||||
params.append(tenant_id)
|
||||
if role:
|
||||
conditions.append("a.role = ?")
|
||||
params.append(role)
|
||||
|
||||
# Exclude accounts with active non-expired leases
|
||||
now = _now_unix()
|
||||
conditions.append(
|
||||
"a.id NOT IN (SELECT account_id FROM account_leases "
|
||||
"WHERE status = 'active' AND expires_at > ?)"
|
||||
)
|
||||
params.append(now)
|
||||
|
||||
where = " AND ".join(conditions)
|
||||
cur.execute(
|
||||
f"SELECT a.id FROM accounts a WHERE {where} "
|
||||
"ORDER BY CASE WHEN a.session_status = 'expired' THEN 1 ELSE 0 END ASC, "
|
||||
"a.last_used_at IS NULL DESC, a.updated_at DESC, a.id DESC",
|
||||
params,
|
||||
)
|
||||
return [int(row[0]) for row in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_account_last_used(account_id: int) -> None:
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"UPDATE accounts SET last_used_at = ?, updated_at = ? WHERE id = ?",
|
||||
(_now_unix(), _now_unix(), account_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_account_session_status(account_id: int, session_status: str) -> None:
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"UPDATE accounts SET session_status = ?, last_login_check_at = ?, updated_at = ? WHERE id = ?",
|
||||
(session_status, _now_unix(), _now_unix(), account_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_account_error(
|
||||
account_id: int,
|
||||
error_code: str,
|
||||
error_message: str,
|
||||
) -> None:
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"UPDATE accounts SET last_error_code = ?, last_error_message = ?, updated_at = ? WHERE id = ?",
|
||||
(error_code, error_message, _now_unix(), account_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def account_exists_with_login_id(conn, platform_key: str, login_id: str, environment: str, role: str) -> bool:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT id, name, platform, phone, profile_dir FROM accounts WHERE id = ?",
|
||||
(aid,),
|
||||
"SELECT id FROM accounts WHERE platform_key = ? AND login_id = ? AND environment = ? AND role = ?",
|
||||
(platform_key, login_id, environment, role),
|
||||
)
|
||||
return cur.fetchone()
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def delete_account_by_id_conn(conn, rid: int) -> None:
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM accounts WHERE id = ?", (rid,))
|
||||
# ============================================================================
|
||||
# Credentials
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def fetch_ids_profile_for_platform_conn(conn, platform_key: str):
|
||||
def insert_credential(
|
||||
conn,
|
||||
account_id: Optional[int],
|
||||
platform_key: str,
|
||||
credential_label: str,
|
||||
credential_type: str,
|
||||
secret_storage: str,
|
||||
secret_ref: Optional[str],
|
||||
secret_mask: Optional[str],
|
||||
expires_at: Optional[int],
|
||||
extra_json: Optional[str],
|
||||
now: int,
|
||||
) -> int:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT id, profile_dir FROM accounts WHERE platform = ?",
|
||||
(platform_key,),
|
||||
"""
|
||||
INSERT INTO credentials
|
||||
(account_id, platform_key, credential_label, credential_type,
|
||||
secret_storage, secret_ref, secret_mask, expires_at, status,
|
||||
extra_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
account_id, platform_key, credential_label, credential_type,
|
||||
secret_storage, secret_ref, secret_mask, expires_at,
|
||||
extra_json or "{}", now, now,
|
||||
),
|
||||
)
|
||||
return cur.fetchall()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def delete_accounts_by_platform_conn(conn, platform_key: str) -> None:
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM accounts WHERE platform = ?", (platform_key,))
|
||||
def get_credential_by_id(credential_id: int) -> Optional[dict]:
|
||||
conn = get_conn()
|
||||
try:
|
||||
conn.row_factory = None
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT id, account_id, platform_key, credential_label, credential_type, "
|
||||
"secret_storage, secret_ref, secret_mask, expires_at, status, last_used_at, "
|
||||
"extra_json, created_at, updated_at FROM credentials WHERE id = ?",
|
||||
(credential_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": row[0],
|
||||
"account_id": row[1],
|
||||
"platform_key": row[2],
|
||||
"credential_label": row[3],
|
||||
"credential_type": row[4],
|
||||
"secret_storage": row[5],
|
||||
"secret_ref": row[6] or "",
|
||||
"secret_mask": row[7] or "",
|
||||
"expires_at": _unix_to_iso_local(row[8]),
|
||||
"status": row[9],
|
||||
"last_used_at": _unix_to_iso_local(row[10]),
|
||||
"created_at": _unix_to_iso_local(row[12]),
|
||||
"updated_at": _unix_to_iso_local(row[13]),
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def fetch_row_platform_phone_conn(conn, platform_key: str, phone_norm: str):
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT id, name, profile_dir FROM accounts WHERE platform = ? AND phone = ?",
|
||||
(platform_key, phone_norm),
|
||||
)
|
||||
return cur.fetchone()
|
||||
def find_credentials(
|
||||
platform_key: str,
|
||||
credential_type: Optional[str] = None,
|
||||
environment: Optional[str] = None,
|
||||
role: Optional[str] = None,
|
||||
status: str = "active",
|
||||
limit: int = 50,
|
||||
) -> list[dict]:
|
||||
"""Find credentials, optionally joining accounts for environment/role filter."""
|
||||
conn = get_conn()
|
||||
try:
|
||||
conn.row_factory = None
|
||||
cur = conn.cursor()
|
||||
conditions = ["c.status = ?"]
|
||||
params = [status]
|
||||
|
||||
conditions.append("c.platform_key = ?")
|
||||
params.append(platform_key)
|
||||
|
||||
if credential_type:
|
||||
conditions.append("c.credential_type = ?")
|
||||
params.append(credential_type)
|
||||
|
||||
# If environment/role filters provided, join accounts table
|
||||
if environment or role:
|
||||
conditions.append("c.account_id = a.id")
|
||||
if environment:
|
||||
conditions.append("a.environment = ?")
|
||||
params.append(environment)
|
||||
if role:
|
||||
conditions.append("a.role = ?")
|
||||
params.append(role)
|
||||
|
||||
where = " AND ".join(conditions)
|
||||
cur.execute(
|
||||
f"SELECT c.id, c.account_id, c.platform_key, c.credential_label, "
|
||||
f"c.credential_type, c.secret_storage, c.secret_ref, c.secret_mask, "
|
||||
f"c.expires_at, c.status, c.last_used_at, c.extra_json, "
|
||||
f"c.created_at, c.updated_at "
|
||||
f"FROM credentials c, accounts a WHERE {where} "
|
||||
f"ORDER BY c.last_used_at IS NULL DESC, c.updated_at DESC, c.id DESC "
|
||||
f"LIMIT ?",
|
||||
params + [int(limit)],
|
||||
)
|
||||
else:
|
||||
where = " AND ".join(conditions)
|
||||
cur.execute(
|
||||
f"SELECT c.id, c.account_id, c.platform_key, c.credential_label, "
|
||||
f"c.credential_type, c.secret_storage, c.secret_ref, c.secret_mask, "
|
||||
f"c.expires_at, c.status, c.last_used_at, c.extra_json, "
|
||||
f"c.created_at, c.updated_at "
|
||||
f"FROM credentials c WHERE {where} "
|
||||
f"ORDER BY c.last_used_at IS NULL DESC, c.updated_at DESC, c.id DESC "
|
||||
f"LIMIT ?",
|
||||
params + [int(limit)],
|
||||
)
|
||||
|
||||
rows = cur.fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
result.append({
|
||||
"id": row[0],
|
||||
"account_id": row[1],
|
||||
"platform_key": row[2],
|
||||
"credential_label": row[3],
|
||||
"credential_type": row[4],
|
||||
"secret_storage": row[5],
|
||||
"secret_ref": row[6] or "",
|
||||
"secret_mask": row[7] or "",
|
||||
"expires_at": _unix_to_iso_local(row[8]),
|
||||
"status": row[9],
|
||||
"last_used_at": _unix_to_iso_local(row[10]),
|
||||
"created_at": _unix_to_iso_local(row[12]),
|
||||
"updated_at": _unix_to_iso_local(row[13]),
|
||||
})
|
||||
return result
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_account_platform_phone_conn(conn, platform_key: str, phone_norm: str) -> None:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"DELETE FROM accounts WHERE platform = ? AND phone = ?",
|
||||
(platform_key, phone_norm),
|
||||
)
|
||||
def update_credential_last_used(credential_id: int) -> None:
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"UPDATE credentials SET last_used_at = ?, updated_at = ? WHERE id = ?",
|
||||
(_now_unix(), _now_unix(), credential_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Leases
|
||||
# ============================================================================
|
||||
|
||||
def acquire_lease(
|
||||
account_id: int,
|
||||
lease_token: str,
|
||||
holder: str,
|
||||
purpose: Optional[str],
|
||||
ttl_sec: int,
|
||||
) -> dict:
|
||||
"""
|
||||
Acquire a lease for an account. Expires active leases first.
|
||||
Returns the lease record dict on success.
|
||||
Raises ValueError if a non-expired active lease already exists.
|
||||
"""
|
||||
log = get_skill_logger()
|
||||
log.info("lease_acquire_attempt account_id=%s holder=%s purpose=%s ttl=%s", account_id, holder, purpose, ttl_sec)
|
||||
|
||||
conn = get_conn()
|
||||
try:
|
||||
conn.row_factory = None
|
||||
cur = conn.cursor()
|
||||
now = _now_unix()
|
||||
|
||||
# Mark any expired active leases as expired
|
||||
cur.execute(
|
||||
"UPDATE account_leases SET status = 'expired', updated_at = ? "
|
||||
"WHERE account_id = ? AND status = 'active' AND expires_at <= ?",
|
||||
(now, account_id, now),
|
||||
)
|
||||
if cur.rowcount > 0:
|
||||
log.info("lease_expired_cleanup account_id=%s count=%s", account_id, cur.rowcount)
|
||||
|
||||
# Check for existing active non-expired lease
|
||||
cur.execute(
|
||||
"SELECT id, lease_token, holder FROM account_leases "
|
||||
"WHERE account_id = ? AND status = 'active' AND expires_at > ?",
|
||||
(account_id, now),
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
if existing:
|
||||
log.warning(
|
||||
"lease_conflict account_id=%s existing_holder=%s existing_token_prefix=%s",
|
||||
account_id, existing[2], existing[1][:8],
|
||||
)
|
||||
raise ValueError(
|
||||
f"Account {account_id} already has an active lease "
|
||||
f"(holder={existing[2]}, token_prefix={existing[1][:8]}) (LEASE_CONFLICT)"
|
||||
)
|
||||
|
||||
expires_at = now + max(1, int(ttl_sec))
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO account_leases
|
||||
(account_id, lease_token, holder, purpose, expires_at, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)
|
||||
""",
|
||||
(account_id, lease_token, holder, purpose, expires_at, now, now),
|
||||
)
|
||||
conn.commit()
|
||||
lease_id = int(cur.lastrowid)
|
||||
log.info("lease_acquire_success account_id=%s lease_id=%s token_prefix=%s", account_id, lease_id, lease_token[:8])
|
||||
|
||||
return {
|
||||
"id": lease_id,
|
||||
"account_id": account_id,
|
||||
"lease_token": lease_token,
|
||||
"holder": holder,
|
||||
"purpose": purpose,
|
||||
"expires_at": _unix_to_iso_local(expires_at),
|
||||
"status": "active",
|
||||
"created_at": _unix_to_iso_local(now),
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def release_lease(lease_token: str) -> bool:
|
||||
"""Release a lease by token. Returns True if found and released."""
|
||||
log = get_skill_logger()
|
||||
log.info("lease_release_attempt token_prefix=%s", lease_token[:8])
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
now = _now_unix()
|
||||
cur.execute(
|
||||
"UPDATE account_leases SET status = 'released', released_at = ?, updated_at = ? "
|
||||
"WHERE lease_token = ? AND status = 'active'",
|
||||
(now, now, lease_token),
|
||||
)
|
||||
conn.commit()
|
||||
if cur.rowcount == 0:
|
||||
log.warning("lease_release_not_found token_prefix=%s", lease_token[:8])
|
||||
return False
|
||||
log.info("lease_release_success token_prefix=%s", lease_token[:8])
|
||||
return True
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_active_leases() -> list[dict]:
|
||||
"""List all active (non-expired, non-released) leases."""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
now = _now_unix()
|
||||
cur.execute(
|
||||
"SELECT id, account_id, lease_token, holder, purpose, expires_at, "
|
||||
"heartbeat_at, released_at, status, created_at, updated_at "
|
||||
"FROM account_leases WHERE status = 'active' AND expires_at > ? "
|
||||
"ORDER BY created_at DESC",
|
||||
(now,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
result = []
|
||||
for r in rows:
|
||||
result.append({
|
||||
"id": r[0],
|
||||
"account_id": r[1],
|
||||
"lease_token": r[2],
|
||||
"holder": r[3],
|
||||
"purpose": r[4] or "",
|
||||
"expires_at": _unix_to_iso_local(r[5]),
|
||||
"heartbeat_at": _unix_to_iso_local(r[6]),
|
||||
"released_at": _unix_to_iso_local(r[7]),
|
||||
"status": r[8],
|
||||
"created_at": _unix_to_iso_local(r[9]),
|
||||
"updated_at": _unix_to_iso_local(r[10]),
|
||||
})
|
||||
return result
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def cleanup_expired_leases() -> int:
|
||||
"""Mark all expired active leases as expired. Returns count cleaned."""
|
||||
log = get_skill_logger()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
now = _now_unix()
|
||||
cur.execute(
|
||||
"UPDATE account_leases SET status = 'expired', updated_at = ? "
|
||||
"WHERE status = 'active' AND expires_at <= ?",
|
||||
(now, now),
|
||||
)
|
||||
count = cur.rowcount
|
||||
conn.commit()
|
||||
if count > 0:
|
||||
log.info("lease_cleanup_expired count=%s", count)
|
||||
return count
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Internal helpers
|
||||
# ============================================================================
|
||||
|
||||
def _safe_json_loads(val: Any, default: Any = None) -> Any:
|
||||
if val is None:
|
||||
return default
|
||||
try:
|
||||
return json.loads(val) if isinstance(val, str) else val
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return default
|
||||
|
||||
@@ -1,21 +1,124 @@
|
||||
"""SQLite connection and schema bootstrap."""
|
||||
import sqlite3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
SQLite connection with schema migration (v1 -> v2).
|
||||
|
||||
from db.schema import ACCOUNTS_TABLE_SQL
|
||||
Migration strategy:
|
||||
- Check PRAGMA user_version.
|
||||
- If version < 2 and old 'accounts' table exists, back it up as
|
||||
'accounts_legacy_backup_<timestamp>' before creating new tables.
|
||||
- New tables are created with CREATE TABLE IF NOT EXISTS.
|
||||
- Logs all migration steps.
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from db.schema import ALL_DDL, SCHEMA_VERSION
|
||||
from util.constants import SKILL_SLUG
|
||||
from util.logging_config import get_skill_logger
|
||||
from util.runtime_paths import get_db_path
|
||||
|
||||
|
||||
def get_conn():
|
||||
return sqlite3.connect(get_db_path())
|
||||
def get_conn() -> sqlite3.Connection:
|
||||
"""Open a connection to the skill database (autocommit off)."""
|
||||
conn = sqlite3.connect(get_db_path())
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=OFF")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db():
|
||||
def _backup_old_accounts(conn: sqlite3.Connection) -> bool:
|
||||
"""Rename old 'accounts' table to 'accounts_legacy_backup_<ts>' if it exists with old schema.
|
||||
Returns True if backup was performed."""
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'"
|
||||
)
|
||||
if not cur.fetchone():
|
||||
return False
|
||||
|
||||
# Check if it's the old schema (has 'phone' column but no 'platform_key')
|
||||
# We just check the column count — old schema had fewer columns.
|
||||
cur.execute("PRAGMA table_info(accounts)")
|
||||
columns = [row[1] for row in cur.fetchall()]
|
||||
# Old schema: id, name, platform, phone, profile_dir, url, extra_json, created_at, updated_at = 9
|
||||
# New schema: many more. If it looks like old, back it up.
|
||||
has_legacy_structure = "phone" in columns and "platform_key" not in columns
|
||||
if not has_legacy_structure:
|
||||
return False
|
||||
|
||||
ts = int(time.time())
|
||||
backup_name = f"accounts_legacy_backup_{ts}"
|
||||
cur.execute(f"ALTER TABLE accounts RENAME TO {backup_name}")
|
||||
conn.commit()
|
||||
logger = get_skill_logger()
|
||||
logger.info(
|
||||
"schema_migration_backup old_table=accounts backup_table=%s",
|
||||
backup_name,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _check_schema_version(conn: sqlite3.Connection) -> int:
|
||||
cur = conn.cursor()
|
||||
cur.execute("PRAGMA user_version")
|
||||
row = cur.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
def _create_new_tables(conn: sqlite3.Connection) -> None:
|
||||
"""Execute ALL_DDL (all CREATE TABLE/INDEX IF NOT EXISTS)."""
|
||||
cur = conn.cursor()
|
||||
cur.executescript(ALL_DDL)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""Idempotent: migrate if needed, create tables, set schema version."""
|
||||
log = get_skill_logger()
|
||||
log.info("schema_init_start")
|
||||
conn = get_conn()
|
||||
try:
|
||||
old_version = _check_schema_version(conn)
|
||||
log.info("schema_current_version=%s", old_version)
|
||||
|
||||
if old_version < SCHEMA_VERSION:
|
||||
if old_version < 2:
|
||||
backed_up = _backup_old_accounts(conn)
|
||||
if backed_up:
|
||||
log.info("schema_init_backup_done old_version=%s", old_version)
|
||||
_create_new_tables(conn)
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
||||
conn.commit()
|
||||
log.info(
|
||||
"schema_init_done version=%s migrated_from=%s",
|
||||
SCHEMA_VERSION,
|
||||
old_version,
|
||||
)
|
||||
else:
|
||||
log.info("schema_init_uptodate version=%s", SCHEMA_VERSION)
|
||||
|
||||
# Ensure all tables exist even if schema version matches
|
||||
_create_new_tables(conn)
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
||||
conn.commit()
|
||||
except Exception:
|
||||
log.exception("schema_init_failure")
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_db_version() -> int:
|
||||
"""Return current PRAGMA user_version without initializing."""
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'")
|
||||
if not cur.fetchone():
|
||||
cur.executescript(ACCOUNTS_TABLE_SQL)
|
||||
conn.commit()
|
||||
cur.execute("PRAGMA user_version")
|
||||
row = cur.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -1,18 +1,145 @@
|
||||
# SQLite 无独立 DATETIME 类型:时间统一存 INTEGER Unix 秒(UTC),查询/JSON 再转 ISO8601。
|
||||
# 下列 DDL 含注释,会原样写入 sqlite_master,便于 Navicat / DBeaver 等查看建表语句。
|
||||
ACCOUNTS_TABLE_SQL = """
|
||||
/* 多平台账号表:与本地 Chromium/Edge 用户数据目录(profile)绑定,供发布类技能读取登录态 */
|
||||
CREATE TABLE accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 账号主键(自增)
|
||||
name TEXT NOT NULL, -- 展示名称,如「搜狐1号」
|
||||
platform TEXT NOT NULL, -- 平台标识,与内置 PLATFORMS 键一致,如 sohu
|
||||
phone TEXT, -- 可选绑定手机号
|
||||
profile_dir TEXT, -- Playwright 用户数据目录(绝对路径);默认可读结构 profiles/<平台展示名>/<手机号>/
|
||||
url TEXT, -- 平台入口或登录页 URL
|
||||
login_status INTEGER NOT NULL DEFAULT 0, -- 是否已登录:0 否 1 是(由脚本校验后写入)
|
||||
last_login_at INTEGER, -- 最近一次登录成功时间,Unix 秒 UTC;未登录过为 NULL
|
||||
extra_json TEXT, -- 扩展字段 JSON
|
||||
created_at INTEGER NOT NULL, -- 记录创建时间,Unix 秒 UTC
|
||||
updated_at INTEGER NOT NULL -- 记录最后更新时间,Unix 秒 UTC
|
||||
);
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Database schema v2 — Credential & Browser Profile Manager.
|
||||
|
||||
Time stored as INTEGER Unix seconds (UTC). DDL comments are preserved in
|
||||
sqlite_master, visible in DBeaver / Navicat.
|
||||
|
||||
Schema version management:
|
||||
PRAGMA user_version = 2 (set in connection.py after migration).
|
||||
"""
|
||||
|
||||
import textwrap
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# platforms — Registry of all platforms (logistics, LLM, content, etc.)
|
||||
# ---------------------------------------------------------------------------
|
||||
PLATFORMS_TABLE_SQL = textwrap.dedent("""\
|
||||
CREATE TABLE IF NOT EXISTS platforms (
|
||||
platform_key TEXT PRIMARY KEY, -- 平台唯一键,例如 maersk / deepseek / sohu
|
||||
display_name TEXT NOT NULL, -- 展示名称,例如 Maersk / DeepSeek / 搜狐号
|
||||
domain TEXT NOT NULL, -- 业务域:logistics / llm / content / ecommerce / generic
|
||||
provider_code TEXT, -- 供应商代码,物流 profile 可复用;例如 maersk、cma_cgm
|
||||
default_url TEXT, -- 默认入口 URL
|
||||
aliases_json TEXT NOT NULL DEFAULT '[]',-- 别名 JSON 数组,中英文别名
|
||||
capabilities_json TEXT NOT NULL DEFAULT '{}', -- 能力声明,例如 {"web":true,"api_key":true,"rpa":true}
|
||||
enabled INTEGER NOT NULL DEFAULT 1,-- 是否启用 (1=yes 0=no)
|
||||
created_at INTEGER NOT NULL, -- 记录创建时间,Unix 秒 UTC
|
||||
updated_at INTEGER NOT NULL -- 记录最后更新时间,Unix 秒 UTC
|
||||
);
|
||||
""")
|
||||
|
||||
PLATFORMS_INDEXES_SQL = textwrap.dedent("""\
|
||||
CREATE INDEX IF NOT EXISTS idx_platforms_domain ON platforms(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_platforms_provider_code ON platforms(provider_code);
|
||||
""")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# accounts — An identity that can be used (web account, RPA profile, API account)
|
||||
# ---------------------------------------------------------------------------
|
||||
ACCOUNTS_TABLE_SQL = textwrap.dedent("""\
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 账号主键(自增)
|
||||
platform_key TEXT NOT NULL, -- 对应 platforms.platform_key
|
||||
account_label TEXT NOT NULL, -- 人可读名称,例如 Maersk simulator booking
|
||||
login_id TEXT, -- 登录标识:邮箱、用户名、手机号、客户代码等
|
||||
login_id_type TEXT NOT NULL DEFAULT 'unknown', -- email / username / phone / customer_code / api_account / unknown
|
||||
tenant_id TEXT, -- 租户/客户/项目标识,可空
|
||||
environment TEXT NOT NULL DEFAULT 'production',-- simulator / staging / production / test
|
||||
role TEXT NOT NULL DEFAULT 'default', -- booking / tracking / admin / sales / api / default
|
||||
provider_code TEXT, -- 冗余供应商码,便于业务 skill 查询
|
||||
profile_dir TEXT, -- Playwright 持久化浏览器用户数据目录;web/rpa 账号使用
|
||||
url TEXT, -- 账号默认入口 URL;为空时用 platforms.default_url
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active / disabled / needs_review
|
||||
session_status TEXT NOT NULL DEFAULT 'unknown', -- unknown / likely_valid / needs_login / expired
|
||||
last_used_at INTEGER, -- 最近被业务 skill pick/使用时间
|
||||
last_login_check_at INTEGER, -- 最近一次登录状态检查时间,可空
|
||||
last_error_code TEXT, -- 最近一次错误代码
|
||||
last_error_message TEXT, -- 最近一次错误消息
|
||||
extra_json TEXT NOT NULL DEFAULT '{}', -- 扩展字段 JSON
|
||||
created_at INTEGER NOT NULL, -- 记录创建时间,Unix 秒 UTC
|
||||
updated_at INTEGER NOT NULL -- 记录最后更新时间,Unix 秒 UTC
|
||||
);
|
||||
""")
|
||||
|
||||
ACCOUNTS_INDEXES_SQL = textwrap.dedent("""\
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_platform ON accounts(platform_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_pick_web ON accounts(platform_key, environment, tenant_id, role, status, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_provider ON accounts(provider_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts(status);
|
||||
""")
|
||||
|
||||
# Note: Unique constraint (platform_key, login_id, tenant_id, environment, role)
|
||||
# is intentionally NOT added here because SQLite allows multiple NULL login_id.
|
||||
# Application-level checks are used when login_id is provided.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# credentials — Secret-type credentials (API Key, Token, Password, etc.)
|
||||
# Raw secret is NEVER stored in SQLite. Only metadata, secret_ref, secret_mask.
|
||||
# ---------------------------------------------------------------------------
|
||||
CREDENTIALS_TABLE_SQL = textwrap.dedent("""\
|
||||
CREATE TABLE IF NOT EXISTS credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 凭据主键(自增)
|
||||
account_id INTEGER, -- 可空;关联 accounts.id;有些 API Key 可直接挂平台,也可挂具体账号
|
||||
platform_key TEXT NOT NULL, -- 平台唯一键
|
||||
credential_label TEXT NOT NULL, -- 人可读名称,例如 DeepSeek main API key
|
||||
credential_type TEXT NOT NULL, -- api_key / password / bearer_token / oauth_client / refresh_token / browser_profile / note
|
||||
secret_storage TEXT NOT NULL, -- none / env / windows_credential
|
||||
secret_ref TEXT, -- env 时为环境变量名;windows_credential 时为 target name;none 时为空
|
||||
secret_mask TEXT, -- 打码展示,例如 sk-****abcd;不能反推出原文
|
||||
expires_at INTEGER, -- 过期时间,Unix 秒 UTC;可空
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active / disabled / expired / needs_rotation
|
||||
last_used_at INTEGER, -- 最近使用时间
|
||||
extra_json TEXT NOT NULL DEFAULT '{}', -- 扩展字段 JSON
|
||||
created_at INTEGER NOT NULL, -- 记录创建时间,Unix 秒 UTC
|
||||
updated_at INTEGER NOT NULL -- 记录最后更新时间,Unix 秒 UTC
|
||||
);
|
||||
""")
|
||||
|
||||
CREDENTIALS_INDEXES_SQL = textwrap.dedent("""\
|
||||
CREATE INDEX IF NOT EXISTS idx_credentials_platform_type ON credentials(platform_key, credential_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_credentials_account ON credentials(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_credentials_status ON credentials(status);
|
||||
""")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# account_leases — RPA / profile concurrency locks
|
||||
# Prevents multiple tasks from opening the same profile_dir simultaneously.
|
||||
# ---------------------------------------------------------------------------
|
||||
ACCOUNT_LEASES_TABLE_SQL = textwrap.dedent("""\
|
||||
CREATE TABLE IF NOT EXISTS account_leases (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 租约主键(自增)
|
||||
account_id INTEGER NOT NULL, -- 关联 accounts.id
|
||||
lease_token TEXT NOT NULL UNIQUE, -- 随机 token,用于 release / heartbeat
|
||||
holder TEXT NOT NULL, -- 持有者,例如 monitor-competitor-rates 或 run id
|
||||
purpose TEXT, -- 用途,例如 maersk_sim_rpa / booking_collect
|
||||
expires_at INTEGER NOT NULL, -- 租约过期时间,Unix 秒 UTC
|
||||
heartbeat_at INTEGER, -- 最近一次心跳时间
|
||||
released_at INTEGER, -- 主动释放时间
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active / released / expired
|
||||
created_at INTEGER NOT NULL, -- 记录创建时间,Unix 秒 UTC
|
||||
updated_at INTEGER NOT NULL -- 记录最后更新时间,Unix 秒 UTC
|
||||
);
|
||||
""")
|
||||
|
||||
ACCOUNT_LEASES_INDEXES_SQL = textwrap.dedent("""\
|
||||
CREATE INDEX IF NOT EXISTS idx_leases_account_status ON account_leases(account_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_leases_token ON account_leases(lease_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_leases_expires ON account_leases(expires_at);
|
||||
""")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full DDL assembly
|
||||
# ---------------------------------------------------------------------------
|
||||
ALL_DDL = (
|
||||
PLATFORMS_TABLE_SQL
|
||||
+ PLATFORMS_INDEXES_SQL
|
||||
+ ACCOUNTS_TABLE_SQL
|
||||
+ ACCOUNTS_INDEXES_SQL
|
||||
+ CREDENTIALS_TABLE_SQL
|
||||
+ CREDENTIALS_INDEXES_SQL
|
||||
+ ACCOUNT_LEASES_TABLE_SQL
|
||||
+ ACCOUNT_LEASES_INDEXES_SQL
|
||||
)
|
||||
|
||||
SCHEMA_VERSION = 2
|
||||
|
||||
1
scripts/jiangchang_skill_core/__init__.py
Normal file
1
scripts/jiangchang_skill_core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Vendored from jiangchang-platform-kit/sdk/jiangchang_skill_core/ — keep runtime_env + unified_logging in sync.
|
||||
137
scripts/jiangchang_skill_core/runtime_env.py
Normal file
137
scripts/jiangchang_skill_core/runtime_env.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
JIANGCHANG_* 数据根与用户目录:解析规则 + 可选本地 CLI 默认值。
|
||||
|
||||
各技能 scripts/jiangchang_skill_core/ 为发布用副本,与 kit 保持同步。
|
||||
|
||||
宿主在 skills.entries 与 Gateway 中注入:
|
||||
- PATH 前缀:{JIANGCHANG_DATA_ROOT}/python-runtime/.venv/(Scripts|bin)
|
||||
- VIRTUAL_ENV、JIANGCHANG_PYTHON_EXE
|
||||
Playwright 使用本机 Chrome/Edge(launch 时 channel=chrome|msedge),不依赖宿主下载的 Chromium 包。
|
||||
技能勿在仓库内维护独立 .venv;依赖以 jiangchang-platform-kit/python-runtime 锁文件为准。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 发版/嵌入宿主前改为 False,或仅通过环境变量 JIANGCHANG_CLI_LOCAL_DEV=1 开启
|
||||
CLI_LOCAL_DEV_ENABLED = True
|
||||
|
||||
# 本地开发且未设置 JIANGCHANG_USER_ID 时注入(与宿主约定一致即可修改)
|
||||
DEFAULT_LOCAL_USER_ID = "10032"
|
||||
|
||||
# Windows 下未设置 JIANGCHANG_DATA_ROOT 时的默认盘路径
|
||||
WIN_DEFAULT_DATA_ROOT = r"D:\jiangchang-data"
|
||||
|
||||
# 匠厂桌面宿主应用根(未设置 JIANGCHANG_APP_ROOT 时 Windows 兜底;技能一般在 {APP}/skills/ 下并列)
|
||||
WIN_DEFAULT_JIANGCHANG_APP_ROOT = r"D:\AI\jiangchang"
|
||||
|
||||
|
||||
def platform_default_data_root() -> str:
|
||||
if sys.platform == "win32":
|
||||
return WIN_DEFAULT_DATA_ROOT
|
||||
return os.path.join(os.path.expanduser("~"), ".jiangchang-data")
|
||||
|
||||
|
||||
def get_data_root() -> str:
|
||||
env = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip()
|
||||
if env:
|
||||
return env
|
||||
return platform_default_data_root()
|
||||
|
||||
|
||||
def get_user_id() -> str:
|
||||
return (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "_anon"
|
||||
|
||||
|
||||
def _looks_like_skills_root(path: str) -> bool:
|
||||
if not path or not os.path.isdir(path):
|
||||
return False
|
||||
for marker in (
|
||||
"llm-manager",
|
||||
"content-manager",
|
||||
"account-manager",
|
||||
"sohu-publisher",
|
||||
"toutiao-publisher",
|
||||
"logistics-tracker",
|
||||
"api-key-vault",
|
||||
):
|
||||
if os.path.isdir(os.path.join(path, marker)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_skills_root() -> str:
|
||||
"""
|
||||
并列技能安装目录(其下为「技能 slug」子目录)。
|
||||
|
||||
优先级:
|
||||
1) JIANGCHANG_SKILLS_ROOT
|
||||
2) CLAW_SKILLS_ROOT
|
||||
3) JIANGCHANG_APP_ROOT:若 {APP}/skills 存在且像技能根则用之;否则若 APP 下已有技能子目录则 APP 即根
|
||||
4) Windows:默认 D:\\AI\\jiangchang 同上规则
|
||||
5) 其他平台:~/.openclaw/skills
|
||||
"""
|
||||
for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"):
|
||||
v = (os.getenv(key) or "").strip()
|
||||
if v:
|
||||
return os.path.normpath(v)
|
||||
|
||||
app = (os.getenv("JIANGCHANG_APP_ROOT") or "").strip()
|
||||
if sys.platform == "win32" and not app:
|
||||
app = WIN_DEFAULT_JIANGCHANG_APP_ROOT
|
||||
if app:
|
||||
nested = os.path.join(app, "skills")
|
||||
if _looks_like_skills_root(nested):
|
||||
return os.path.normpath(nested)
|
||||
if _looks_like_skills_root(app):
|
||||
return os.path.normpath(app)
|
||||
|
||||
if sys.platform == "win32":
|
||||
nested = os.path.join(WIN_DEFAULT_JIANGCHANG_APP_ROOT, "skills")
|
||||
if _looks_like_skills_root(nested):
|
||||
return os.path.normpath(nested)
|
||||
if _looks_like_skills_root(WIN_DEFAULT_JIANGCHANG_APP_ROOT):
|
||||
return os.path.normpath(WIN_DEFAULT_JIANGCHANG_APP_ROOT)
|
||||
|
||||
return os.path.normpath(os.path.join(os.path.expanduser("~"), ".openclaw", "skills"))
|
||||
|
||||
|
||||
def get_sibling_skills_root(skill_scripts_dir: str | None = None) -> str:
|
||||
"""
|
||||
编排子进程调用兄弟技能时用:先根据本技能 scripts 目录推断并列根;若目录下能识别出兄弟技能
|
||||
则用之(开发仓 D:\\OpenClaw 与安装目录 ~/.openclaw/skills 均满足「技能根之上一级 = 并列根」),
|
||||
避免全局 JIANGCHANG_SKILLS_ROOT / CLAW_SKILLS_ROOT 指向与当前检出不一致时错调兄弟进程。
|
||||
推断失败时再读上述环境变量,最后回落 get_skills_root()。
|
||||
"""
|
||||
if skill_scripts_dir:
|
||||
scripts = os.path.abspath(skill_scripts_dir)
|
||||
skill_root = os.path.dirname(scripts)
|
||||
inferred = os.path.dirname(skill_root)
|
||||
if _looks_like_skills_root(inferred):
|
||||
return os.path.normpath(inferred)
|
||||
|
||||
for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"):
|
||||
v = (os.getenv(key) or "").strip()
|
||||
if v:
|
||||
return os.path.normpath(v)
|
||||
|
||||
return get_skills_root()
|
||||
|
||||
|
||||
def apply_cli_local_defaults() -> None:
|
||||
"""
|
||||
在 CLI 最早阶段调用(main.py 在 import 业务包之前)。
|
||||
宿主已设置 JIANGCHANG_* 时不会覆盖。
|
||||
"""
|
||||
enabled = CLI_LOCAL_DEV_ENABLED
|
||||
if not enabled:
|
||||
v = (os.getenv("JIANGCHANG_CLI_LOCAL_DEV") or "").strip().lower()
|
||||
enabled = v in ("1", "true", "yes", "on")
|
||||
if not enabled:
|
||||
return
|
||||
if not (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip():
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = platform_default_data_root()
|
||||
if not (os.getenv("JIANGCHANG_USER_ID") or "").strip():
|
||||
os.environ["JIANGCHANG_USER_ID"] = DEFAULT_LOCAL_USER_ID.strip()
|
||||
158
scripts/jiangchang_skill_core/unified_logging.py
Normal file
158
scripts/jiangchang_skill_core/unified_logging.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
统一文件日志:{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log
|
||||
按日轮转;行内带 trace_id 与 skill_slug,便于跨技能排查。
|
||||
|
||||
实现为各技能 scripts/jiangchang_skill_core/ 下的同名副本之源,修改后请同步到各技能。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
from typing import Optional
|
||||
|
||||
from .runtime_env import get_data_root, get_user_id
|
||||
|
||||
_skill_slug: str = ""
|
||||
_logger_name: str = ""
|
||||
|
||||
|
||||
def get_unified_logs_dir() -> str:
|
||||
path = os.path.join(get_data_root(), get_user_id(), "logs")
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def get_skill_log_file_path() -> str:
|
||||
"""与 setup 写入的主日志文件一致(供终端提示等)。"""
|
||||
override = (os.getenv("JIANGCHANG_LOG_FILE") or "").strip()
|
||||
if override:
|
||||
parent = os.path.dirname(os.path.abspath(override))
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
return os.path.abspath(override)
|
||||
return os.path.join(get_unified_logs_dir(), "jiangchang.log")
|
||||
|
||||
|
||||
def ensure_trace_for_process() -> str:
|
||||
"""本进程调用链 ID:沿用 JIANGCHANG_TRACE_ID,否则生成并写入环境(子进程可继承)。"""
|
||||
existing = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip()
|
||||
if existing:
|
||||
return existing
|
||||
tid = uuid.uuid4().hex[:12]
|
||||
os.environ["JIANGCHANG_TRACE_ID"] = tid
|
||||
return tid
|
||||
|
||||
|
||||
def subprocess_env_with_trace(environ: Optional[dict] = None) -> dict:
|
||||
"""subprocess.run(..., env=...) 时使用,保证带上当前 trace。"""
|
||||
ensure_trace_for_process()
|
||||
tid = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip()
|
||||
if not tid:
|
||||
tid = ensure_trace_for_process()
|
||||
base = os.environ if environ is None else environ
|
||||
return {**base, "JIANGCHANG_TRACE_ID": tid}
|
||||
|
||||
|
||||
class _SkillContextFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
record.trace_id = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip() or "-"
|
||||
record.skill_slug = _skill_slug or "-"
|
||||
return True
|
||||
|
||||
|
||||
_FORMAT = (
|
||||
"%(asctime)s | %(levelname)-8s | %(trace_id)s | %(skill_slug)s | %(name)s | %(message)s"
|
||||
)
|
||||
_DATEFMT = "%Y-%m-%dT%H:%M:%S"
|
||||
|
||||
|
||||
def _log_level_from_env() -> int:
|
||||
v = (os.getenv("JIANGCHANG_LOG_LEVEL") or "INFO").strip().upper()
|
||||
return getattr(logging, v, None) or logging.INFO
|
||||
|
||||
|
||||
def _backup_count() -> int:
|
||||
try:
|
||||
n = int((os.getenv("JIANGCHANG_LOG_BACKUP_COUNT") or "30").strip())
|
||||
return max(1, min(n, 365))
|
||||
except ValueError:
|
||||
return 30
|
||||
|
||||
|
||||
def setup_skill_logging(skill_slug: str, logger_name: str) -> None:
|
||||
"""
|
||||
幂等:为指定 logger 挂载统一文件日志与可选 stderr。
|
||||
须在进程早期调用(如 CLI main 在业务日志之前)。
|
||||
"""
|
||||
global _skill_slug, _logger_name
|
||||
ensure_trace_for_process()
|
||||
_skill_slug = skill_slug
|
||||
_logger_name = logger_name
|
||||
|
||||
log = logging.getLogger(logger_name)
|
||||
if log.handlers:
|
||||
return
|
||||
log.setLevel(_log_level_from_env())
|
||||
path = get_skill_log_file_path()
|
||||
fh = TimedRotatingFileHandler(
|
||||
path,
|
||||
when="midnight",
|
||||
interval=1,
|
||||
backupCount=_backup_count(),
|
||||
encoding="utf-8",
|
||||
delay=True,
|
||||
)
|
||||
fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT)
|
||||
fh.setFormatter(fmt)
|
||||
fh.addFilter(_SkillContextFilter())
|
||||
log.addHandler(fh)
|
||||
if (os.getenv("JIANGCHANG_LOG_TO_STDERR") or "").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
):
|
||||
sh = logging.StreamHandler(sys.stderr)
|
||||
sh.setLevel(logging.WARNING)
|
||||
sh.setFormatter(fmt)
|
||||
sh.addFilter(_SkillContextFilter())
|
||||
log.addHandler(sh)
|
||||
log.propagate = False
|
||||
|
||||
|
||||
def get_skill_logger() -> logging.Logger:
|
||||
if not _logger_name:
|
||||
raise RuntimeError("get_skill_logger: call setup_skill_logging first")
|
||||
return logging.getLogger(_logger_name)
|
||||
|
||||
|
||||
def attach_unified_file_handler(
|
||||
log_path: str,
|
||||
*,
|
||||
skill_slug: str,
|
||||
logger_name: str,
|
||||
level: int = logging.DEBUG,
|
||||
) -> logging.Logger:
|
||||
"""
|
||||
独立子进程内追加同一日志文件(如 login 检测子进程),格式与主进程一致。
|
||||
"""
|
||||
global _skill_slug
|
||||
ensure_trace_for_process()
|
||||
_skill_slug = skill_slug
|
||||
lg = logging.getLogger(logger_name)
|
||||
lg.handlers.clear()
|
||||
lg.setLevel(level)
|
||||
parent = os.path.dirname(os.path.abspath(log_path))
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
fh = logging.FileHandler(log_path, encoding="utf-8")
|
||||
fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT)
|
||||
fh.setFormatter(fmt)
|
||||
fh.addFilter(_SkillContextFilter())
|
||||
lg.addHandler(fh)
|
||||
lg.propagate = False
|
||||
return lg
|
||||
@@ -3,8 +3,17 @@ account-manager CLI 入口。
|
||||
|
||||
分层:cli(argv)→ service(用例)→ db(SQLite);共用工具在 util/。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if _scripts_dir not in sys.path:
|
||||
sys.path.insert(0, _scripts_dir)
|
||||
|
||||
from jiangchang_skill_core.runtime_env import apply_cli_local_defaults
|
||||
|
||||
apply_cli_local_defaults()
|
||||
|
||||
# Windows GBK 编码兼容修复(须在任何业务 import 与 print 之前)
|
||||
if sys.platform == "win32":
|
||||
import io
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,29 @@
|
||||
"""Chromium/Edge 检测与 Playwright 登录、仅打开浏览器。"""
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Chromium/Edge browser profile opener.
|
||||
|
||||
Uses system Chrome/Edge channel, Playwright launch_persistent_context.
|
||||
Does NOT download Playwright Chromium.
|
||||
Does NOT check login status.
|
||||
Does NOT write secret to SQLite.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from db.accounts_repo import get_account_by_id, mark_login_status
|
||||
from util.logging_config import get_skill_logger, get_skill_log_file_path
|
||||
from util.platforms import (
|
||||
PLATFORMS,
|
||||
PLATFORM_URLS,
|
||||
_LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS,
|
||||
from db.connection import init_db
|
||||
from db.accounts_repo import get_account_by_id
|
||||
from util.logging_config import (
|
||||
get_skill_logger,
|
||||
subprocess_env_with_trace,
|
||||
)
|
||||
from util.platforms import PLATFORMS
|
||||
|
||||
# 子进程入口脚本与本模块同目录(独立解释器执行,非 import)
|
||||
_SERVICE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_SCRIPTS_ROOT = os.path.dirname(_SERVICE_DIR)
|
||||
|
||||
|
||||
def _win_find_exe(candidates):
|
||||
@@ -62,224 +69,63 @@ def resolve_chromium_channel():
|
||||
|
||||
|
||||
def _print_browser_install_hint():
|
||||
print("❌ 未检测到 Google Chrome 或 Microsoft Edge,请先安装后再登录:")
|
||||
print("❌ 未检测到 Google Chrome 或 Microsoft Edge,请先安装:")
|
||||
print(" • Chrome: https://www.google.com/chrome/")
|
||||
print(" • Edge: https://www.microsoft.com/zh-cn/edge")
|
||||
|
||||
|
||||
def _login_timeout_seconds():
|
||||
try:
|
||||
return max(60, int((os.getenv("JIANGCHANG_LOGIN_TIMEOUT_SECONDS") or "300").strip()))
|
||||
except ValueError:
|
||||
return 300
|
||||
|
||||
|
||||
def _login_poll_interval_seconds():
|
||||
"""轮询间隔,默认 1.5s。"""
|
||||
try:
|
||||
v = float((os.getenv("JIANGCHANG_LOGIN_POLL_INTERVAL_SECONDS") or "1.5").strip())
|
||||
return max(0.5, min(v, 10.0))
|
||||
except ValueError:
|
||||
return 1.5
|
||||
|
||||
|
||||
def _login_dom_grace_seconds() -> float:
|
||||
"""goto 后等待再开始 DOM 判定,减轻跳转中途误判。默认 4s,JIANGCHANG_LOGIN_DOM_GRACE_SECONDS。"""
|
||||
try:
|
||||
v = float((os.getenv("JIANGCHANG_LOGIN_DOM_GRACE_SECONDS") or "4").strip())
|
||||
return max(0.0, min(v, 60.0))
|
||||
except ValueError:
|
||||
return 4.0
|
||||
|
||||
|
||||
def _login_out_bundle_for(platform_key: str) -> dict:
|
||||
"""传给子进程:anchor + 未登录可见的选择器列表。"""
|
||||
spec = PLATFORMS.get(platform_key, {}) if platform_key else {}
|
||||
anchor = (urlparse(PLATFORM_URLS.get(platform_key, "") or "").netloc or "").lower()
|
||||
spec_lo_dom = [
|
||||
str(s).strip()
|
||||
for s in (spec.get("login_logged_out_selectors") or [])
|
||||
if s is not None and str(s).strip()
|
||||
]
|
||||
if bool(spec.get("login_skip_generic_logged_out_dom")):
|
||||
logged_out_dom_selectors = list(spec_lo_dom)
|
||||
else:
|
||||
logged_out_dom_selectors = list(_LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS) + spec_lo_dom
|
||||
return {
|
||||
"logged_out_dom_selectors": logged_out_dom_selectors,
|
||||
"anchor_host": anchor,
|
||||
}
|
||||
|
||||
|
||||
def cmd_login(account_id):
|
||||
def cmd_open(account_id):
|
||||
"""
|
||||
打开该账号的持久化浏览器,仅用于肉眼核对。
|
||||
不写数据库、不判定是否已登录。
|
||||
"""
|
||||
log = get_skill_logger()
|
||||
log.info("login_command account_id=%r", account_id)
|
||||
init_db()
|
||||
log.info("browser_open_start account_id=%r", account_id)
|
||||
|
||||
target = get_account_by_id(account_id)
|
||||
if not target:
|
||||
log.warning("login_aborted account_not_found account_id=%r", account_id)
|
||||
log.warning("browser_open_error account_id=%r not_found", account_id)
|
||||
print("ERROR:ACCOUNT_NOT_FOUND")
|
||||
return
|
||||
|
||||
channel = resolve_chromium_channel()
|
||||
if not channel:
|
||||
log.warning("login_aborted no_chromium_channel")
|
||||
_print_browser_install_hint()
|
||||
return
|
||||
|
||||
try:
|
||||
import playwright # noqa: F401
|
||||
except ImportError:
|
||||
log.error("login_aborted playwright_missing")
|
||||
print("ERROR:需要 playwright:pip install playwright && playwright install chromium")
|
||||
print(
|
||||
"ERROR:需要 playwright Python 包。"
|
||||
)
|
||||
return
|
||||
|
||||
profile_dir = (target.get("profile_dir") or "").strip()
|
||||
if not profile_dir:
|
||||
log.warning("login_aborted profile_dir_empty account_id=%s", target.get("id"))
|
||||
print("ERROR:PROFILE_DIR_MISSING 库中 profile_dir 为空。")
|
||||
log.warning("browser_open_error account_id=%s profile_dir_missing", account_id)
|
||||
print("ERROR:PROFILE_DIR_MISSING 账号中 profile_dir 为空。")
|
||||
return
|
||||
os.makedirs(profile_dir, exist_ok=True)
|
||||
url = (target.get("url") or "").strip() or PLATFORM_URLS.get(target["platform"], "https://www.google.com")
|
||||
platform = (target.get("platform") or "").strip().lower()
|
||||
timeout_sec = _login_timeout_seconds()
|
||||
poll_sec = _login_poll_interval_seconds()
|
||||
dom_grace_sec = _login_dom_grace_seconds()
|
||||
|
||||
browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge"
|
||||
print(f"正在为账号 [{target['name']}] 打开 {browser_name} …")
|
||||
print(f"访问地址:{url}")
|
||||
print(
|
||||
"请在窗口内完成登录。系统根据页面是否仍出现「登录」等未登录控件判断;"
|
||||
"成功后自动关闭窗口并写入状态(无需手动关浏览器)。"
|
||||
)
|
||||
print(
|
||||
f"页面加载后先等待约 {dom_grace_sec:g} 秒再开始检测(减轻跳转误判,可用 JIANGCHANG_LOGIN_DOM_GRACE_SECONDS 调整)。"
|
||||
)
|
||||
print(f"轮询间隔约 {poll_sec} 秒;全程最长 {timeout_sec} 秒。")
|
||||
# Use account url, fall back to platform default
|
||||
platform_spec = PLATFORMS.get(target["platform_key"], {})
|
||||
url = (target.get("url") or "").strip() or platform_spec.get("default_url", "")
|
||||
|
||||
log_file = get_skill_log_file_path()
|
||||
log.info(
|
||||
"login_browser_start account_id=%s platform=%s channel=%s timeout_sec=%s poll_sec=%s "
|
||||
"dom_grace_sec=%s profile_dir=%s start_url=%s log_file=%s",
|
||||
target.get("id"),
|
||||
platform,
|
||||
channel,
|
||||
timeout_sec,
|
||||
poll_sec,
|
||||
dom_grace_sec,
|
||||
"browser_open_context profile_dir=%s url=%s channel=%s platform=%s",
|
||||
profile_dir,
|
||||
url,
|
||||
log_file,
|
||||
channel,
|
||||
target.get("platform_key"),
|
||||
)
|
||||
login_runner_path = os.path.join(_SERVICE_DIR, "login_child_runner.py")
|
||||
cfg = {
|
||||
"channel": channel,
|
||||
"profile_dir": profile_dir,
|
||||
"url": url,
|
||||
"timeout_sec": timeout_sec,
|
||||
"poll_interval": poll_sec,
|
||||
"dom_grace_sec": dom_grace_sec,
|
||||
"login_detect_bundle": _login_out_bundle_for(platform),
|
||||
"log_file": log_file,
|
||||
}
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".json", delete=False, encoding="utf-8"
|
||||
) as rf:
|
||||
result_path = rf.name
|
||||
cfg["result_path"] = result_path
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".json", delete=False, encoding="utf-8"
|
||||
) as jf:
|
||||
json.dump(cfg, jf, ensure_ascii=False)
|
||||
cfg_path = jf.name
|
||||
proc_rc = None
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[sys.executable, login_runner_path, cfg_path],
|
||||
timeout=timeout_sec + 180,
|
||||
)
|
||||
proc_rc = r.returncode
|
||||
if proc_rc != 0:
|
||||
print("⚠️ 浏览器进程异常退出,将仅根据已写入的检测结果更新状态")
|
||||
log.warning("login_subprocess_nonzero_return code=%s", proc_rc)
|
||||
except subprocess.TimeoutExpired as ex:
|
||||
print("⚠️ 等待浏览器超时,将仅根据已写入的检测结果更新状态")
|
||||
log.warning("login_subprocess_timeout err=%s", ex)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(cfg_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
interactive_ok = False
|
||||
try:
|
||||
with open(result_path, encoding="utf-8") as rf:
|
||||
interactive_ok = bool(json.load(rf).get("interactive_ok"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
os.unlink(result_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
time.sleep(0.5)
|
||||
ok = interactive_ok
|
||||
log.info(
|
||||
"login_finished account_id=%s interactive_ok=%s subprocess_rc=%s marking_db=%s",
|
||||
target.get("id"),
|
||||
interactive_ok,
|
||||
proc_rc,
|
||||
ok,
|
||||
)
|
||||
mark_login_status(target["id"], ok)
|
||||
if ok:
|
||||
print("✅ 已判定登录成功,状态已写入数据库")
|
||||
else:
|
||||
log.warning(
|
||||
"login_not_detected account_id=%s platform=%s see_log=%s",
|
||||
target.get("id"),
|
||||
platform,
|
||||
log_file,
|
||||
)
|
||||
print("⚠️ 未检测到有效登录,状态为未登录。请关闭其他占用该用户目录的浏览器后重试,或延长 JIANGCHANG_LOGIN_TIMEOUT_SECONDS 后再登录。")
|
||||
print(f"ℹ️ 详细轮询日志见:{log_file}(可将 JIANGCHANG_LOG_LEVEL=DEBUG 打开更细粒度)")
|
||||
|
||||
|
||||
def cmd_open(account_id):
|
||||
"""打开该账号的持久化浏览器,仅用于肉眼确认是否已登录;不写数据库。"""
|
||||
get_skill_logger().info("open account_id=%r", account_id)
|
||||
target = get_account_by_id(account_id)
|
||||
if not target:
|
||||
get_skill_logger().warning("open_not_found account_id=%r", account_id)
|
||||
print("ERROR:ACCOUNT_NOT_FOUND")
|
||||
return
|
||||
|
||||
channel = resolve_chromium_channel()
|
||||
if not channel:
|
||||
_print_browser_install_hint()
|
||||
return
|
||||
|
||||
try:
|
||||
import playwright # noqa: F401
|
||||
except ImportError:
|
||||
print("ERROR:需要 playwright:pip install playwright && playwright install chromium")
|
||||
return
|
||||
|
||||
profile_dir = (target.get("profile_dir") or "").strip()
|
||||
if not profile_dir:
|
||||
print("ERROR:PROFILE_DIR_MISSING 库中 profile_dir 为空。")
|
||||
return
|
||||
os.makedirs(profile_dir, exist_ok=True)
|
||||
url = (target.get("url") or "").strip() or PLATFORM_URLS.get(
|
||||
target["platform"], "https://www.google.com"
|
||||
)
|
||||
browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge"
|
||||
print(f"正在打开 [{target['name']}] 的 {browser_name}(仅查看,不写入数据库)")
|
||||
print(f"正在打开 [{target['account_label']}] 的 {browser_name}(仅查看,不写入数据库)")
|
||||
print(f"地址:{url}")
|
||||
print("请在本窗口中自行确认登录态。关闭浏览器后命令结束。")
|
||||
print("需要自动检测并写入数据库时,请执行:python main.py login <id>")
|
||||
print("本命令不做登录检测。")
|
||||
|
||||
open_runner_path = os.path.join(_SERVICE_DIR, "open_child_runner.py")
|
||||
cfg = {"channel": channel, "profile_dir": profile_dir, "url": url}
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".json", delete=False, encoding="utf-8"
|
||||
@@ -287,7 +133,15 @@ def cmd_open(account_id):
|
||||
json.dump(cfg, jf, ensure_ascii=False)
|
||||
cfg_path = jf.name
|
||||
try:
|
||||
subprocess.run([sys.executable, open_runner_path, cfg_path])
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "service.open_child_runner", cfg_path],
|
||||
cwd=_SCRIPTS_ROOT,
|
||||
env=subprocess_env_with_trace(),
|
||||
)
|
||||
log.info("browser_open_close account_id=%s channel=%s", account_id, channel)
|
||||
except Exception as e:
|
||||
log.error("browser_open_error account_id=%s error=%s", account_id, str(e))
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
os.unlink(cfg_path)
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
"""Playwright 登录检测子进程:由 browser_service.cmd_login 以独立解释器启动,argv[1] 为 JSON 配置路径。"""
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
|
||||
def page_location_href(p):
|
||||
# Prefer location.href over page.url for SPA (e.g. Sohu shell path lag).
|
||||
try:
|
||||
href = p.evaluate("() => location.href")
|
||||
if href and str(href).strip():
|
||||
return str(href).strip()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return (p.url or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def host_matches_anchor(netloc, anchor):
|
||||
h = (netloc or "").lower().strip()
|
||||
a = (anchor or "").lower().strip()
|
||||
if not a:
|
||||
return True
|
||||
if not h:
|
||||
return False
|
||||
return h == a or h.endswith("." + a) or a.endswith("." + h)
|
||||
|
||||
|
||||
def loc_first_visible(pg, selectors):
|
||||
for sel in selectors:
|
||||
s = str(sel).strip()
|
||||
if not s:
|
||||
continue
|
||||
try:
|
||||
loc = pg.locator(s).first
|
||||
if loc.is_visible(timeout=500):
|
||||
return True, s
|
||||
except Exception:
|
||||
pass
|
||||
return False, None
|
||||
|
||||
|
||||
def list_pages_on_anchor(ctx, main_page, bundle):
|
||||
anchor = (bundle.get("anchor_host") or "").strip().lower()
|
||||
seen_ids = set()
|
||||
pages = []
|
||||
for pg in list(ctx.pages or []):
|
||||
try:
|
||||
i = id(pg)
|
||||
if i not in seen_ids:
|
||||
seen_ids.add(i)
|
||||
pages.append(pg)
|
||||
except Exception:
|
||||
pages.append(pg)
|
||||
if main_page is not None:
|
||||
try:
|
||||
i = id(main_page)
|
||||
if i not in seen_ids:
|
||||
seen_ids.add(i)
|
||||
pages.append(main_page)
|
||||
except Exception:
|
||||
pages.append(main_page)
|
||||
out = []
|
||||
for pg in pages:
|
||||
try:
|
||||
href = page_location_href(pg)
|
||||
except Exception:
|
||||
continue
|
||||
if not href or str(href).lower().startswith("about:"):
|
||||
continue
|
||||
try:
|
||||
host = urlparse(href).netloc.lower()
|
||||
except Exception:
|
||||
continue
|
||||
if anchor and not host_matches_anchor(host, anchor):
|
||||
continue
|
||||
out.append(pg)
|
||||
return out
|
||||
|
||||
|
||||
def evaluate_logged_out_dom(ctx, main_page, bundle):
|
||||
selectors = [
|
||||
str(x).strip()
|
||||
for x in (bundle.get("logged_out_dom_selectors") or [])
|
||||
if x is not None and str(x).strip()
|
||||
]
|
||||
if not selectors:
|
||||
return True, "no_logged_out_dom_selectors_configured"
|
||||
for pg in list_pages_on_anchor(ctx, main_page, bundle):
|
||||
try:
|
||||
href = page_location_href(pg)
|
||||
except Exception:
|
||||
href = ""
|
||||
hit, which = loc_first_visible(pg, selectors)
|
||||
if hit:
|
||||
return True, "logged_out_dom=%r url=%r" % (which, href)
|
||||
return False, ""
|
||||
|
||||
|
||||
def main():
|
||||
with open(sys.argv[1], encoding="utf-8") as f:
|
||||
c = json.load(f)
|
||||
bundle = c.get("login_detect_bundle") or {}
|
||||
poll = float(c.get("poll_interval", 1.5))
|
||||
try:
|
||||
dom_grace = float(c.get("dom_grace_sec", 4.0))
|
||||
except (TypeError, ValueError):
|
||||
dom_grace = 4.0
|
||||
if dom_grace < 0:
|
||||
dom_grace = 0.0
|
||||
t0 = time.time()
|
||||
deadline = t0 + float(c["timeout_sec"])
|
||||
interactive_ok = False
|
||||
result_path = c.get("result_path") or ""
|
||||
log_path = (c.get("log_file") or "").strip()
|
||||
lg = logging.getLogger("openclaw.skill.account_manager.login_child")
|
||||
if log_path:
|
||||
lg.handlers.clear()
|
||||
lg.setLevel(logging.DEBUG)
|
||||
_fh = logging.FileHandler(log_path, encoding="utf-8")
|
||||
_fh.setFormatter(
|
||||
logging.Formatter(
|
||||
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
||||
datefmt="%Y-%m-%dT%H:%M:%S",
|
||||
)
|
||||
)
|
||||
lg.addHandler(_fh)
|
||||
lg.propagate = False
|
||||
last_eval_detail = ""
|
||||
with sync_playwright() as p:
|
||||
ctx = p.chromium.launch_persistent_context(
|
||||
user_data_dir=c["profile_dir"],
|
||||
headless=False,
|
||||
channel=c["channel"],
|
||||
no_viewport=True,
|
||||
args=["--start-maximized"],
|
||||
)
|
||||
try:
|
||||
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
||||
page.goto(c["url"], wait_until="domcontentloaded", timeout=60000)
|
||||
if dom_grace > 0:
|
||||
time.sleep(dom_grace)
|
||||
if lg.handlers:
|
||||
lg.info(
|
||||
"goto_done initial_pages=%s poll_interval_sec=%s dom_grace_sec=%s",
|
||||
len(ctx.pages or []),
|
||||
poll,
|
||||
dom_grace,
|
||||
)
|
||||
while time.time() < deadline:
|
||||
iter_start = time.time()
|
||||
try:
|
||||
still_logged_out, detail = evaluate_logged_out_dom(ctx, page, bundle)
|
||||
ok = not still_logged_out
|
||||
last_eval_detail = detail
|
||||
if lg.handlers:
|
||||
parts = []
|
||||
for tab in list(ctx.pages or []):
|
||||
try:
|
||||
href = page_location_href(tab)
|
||||
pu = (tab.url or "").strip()
|
||||
parts.append("href=%r playwright_url=%r" % (href, pu))
|
||||
except Exception:
|
||||
parts.append("(tab_read_error)")
|
||||
lg.debug(
|
||||
"poll iter_start_elapsed=%.1fs deadline_in=%.1fs tabs=%s poll_interval_sec=%s logged_out=%s ok=%s detail=%s | %s",
|
||||
iter_start - t0,
|
||||
deadline - iter_start,
|
||||
len(ctx.pages or []),
|
||||
poll,
|
||||
still_logged_out,
|
||||
ok,
|
||||
detail,
|
||||
" ; ".join(parts) if parts else "no_tabs",
|
||||
)
|
||||
if ok:
|
||||
interactive_ok = True
|
||||
if lg.handlers:
|
||||
lg.info("login_detected_ok %s", detail)
|
||||
break
|
||||
except Exception as ex:
|
||||
if lg.handlers:
|
||||
lg.warning("poll_exception err=%s", ex, exc_info=True)
|
||||
spent = time.time() - iter_start
|
||||
time.sleep(max(0.0, poll - spent))
|
||||
if not interactive_ok:
|
||||
if lg.handlers:
|
||||
lg.warning("login_poll_exhausted detail=%s", last_eval_detail)
|
||||
rem = max(0.0, deadline - time.time())
|
||||
if rem > 0:
|
||||
try:
|
||||
ctx.wait_for_event("close", timeout=rem * 1000)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
if lg.handlers:
|
||||
lg.info("login_success_closing_browser_immediately")
|
||||
except Exception as e:
|
||||
if lg.handlers:
|
||||
lg.exception("login_runner_fatal err=%s", e)
|
||||
print(e, file=sys.stderr)
|
||||
raise
|
||||
finally:
|
||||
if result_path:
|
||||
try:
|
||||
with open(result_path, "w", encoding="utf-8") as rf:
|
||||
json.dump({"interactive_ok": interactive_ok}, rf, ensure_ascii=False)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ctx.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -4,18 +4,30 @@ import sys
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from util.playwright_stealth import (
|
||||
STEALTH_INIT_SCRIPT,
|
||||
persistent_context_launch_parts,
|
||||
stealth_enabled,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
with open(sys.argv[1], encoding="utf-8") as f:
|
||||
c = json.load(f)
|
||||
with sync_playwright() as p:
|
||||
ctx = p.chromium.launch_persistent_context(
|
||||
args, ignore_automation = persistent_context_launch_parts()
|
||||
lc_kw = dict(
|
||||
user_data_dir=c["profile_dir"],
|
||||
headless=False,
|
||||
channel=c["channel"],
|
||||
no_viewport=True,
|
||||
args=["--start-maximized"],
|
||||
args=args,
|
||||
)
|
||||
if ignore_automation is not None:
|
||||
lc_kw["ignore_default_args"] = ignore_automation
|
||||
ctx = p.chromium.launch_persistent_context(**lc_kw)
|
||||
if stealth_enabled():
|
||||
ctx.add_init_script(STEALTH_INIT_SCRIPT)
|
||||
try:
|
||||
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
||||
page.goto(c["url"], wait_until="domcontentloaded", timeout=60000)
|
||||
|
||||
306
scripts/service/secret_store.py
Normal file
306
scripts/service/secret_store.py
Normal file
@@ -0,0 +1,306 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Secret storage abstraction layer.
|
||||
|
||||
Supported storage backends:
|
||||
- none : No secret stored (e.g., browser profile login state)
|
||||
- env : Secret is read from environment variable at runtime
|
||||
- windows_credential : Secret stored in Windows Credential Manager (ctypes)
|
||||
|
||||
Raw secret values are NEVER logged or written to SQLite.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
from util.logging_config import get_skill_logger
|
||||
|
||||
|
||||
def mask_secret(secret: str) -> str:
|
||||
"""
|
||||
Return a masked version of the secret for display/storage.
|
||||
Never returns the full original secret or a reversible prefix.
|
||||
|
||||
Examples:
|
||||
- Long API key -> \"****abcd\" (last 4 chars only, after separator)
|
||||
- Short / empty -> \"****\"
|
||||
"""
|
||||
if secret is None:
|
||||
return "****"
|
||||
s = str(secret).strip()
|
||||
if not s:
|
||||
return "****"
|
||||
if len(s) <= 4:
|
||||
return "****"
|
||||
return "****" + s[-4:]
|
||||
|
||||
|
||||
def make_windows_credential_target(
|
||||
platform_key: str,
|
||||
credential_type: str,
|
||||
credential_id_or_label: str,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a Windows Credential Manager target name for a credential.
|
||||
|
||||
Format: openclaw/account-manager/{user_id}/{platform_key}/{credential_type}/{uuid_short}
|
||||
"""
|
||||
user_id = (
|
||||
(os.getenv("CLAW_USER_ID") or os.getenv("JIANGCHANG_USER_ID") or "").strip()
|
||||
or "_anon"
|
||||
)
|
||||
safe_label = re.sub(r"[^a-zA-Z0-9_-]", "_", credential_id_or_label)[:32]
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
return (
|
||||
f"openclaw/account-manager/{user_id}/"
|
||||
f"{platform_key}/{credential_type}/{safe_label}_{uid}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Write
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def write_secret(storage: str, secret_ref: str, secret_value: str) -> None:
|
||||
"""
|
||||
Write a secret to the specified storage backend.
|
||||
Raises ValueError on unsupported storage or write failure.
|
||||
"""
|
||||
log = get_skill_logger()
|
||||
log.info(
|
||||
"secret_write_attempt storage=%s ref_prefix=%s",
|
||||
storage,
|
||||
secret_ref[:16] if secret_ref else "none",
|
||||
)
|
||||
storage_n = storage.strip().lower()
|
||||
|
||||
if storage_n == "none":
|
||||
log.info("secret_write_success storage=none ref=%s", secret_ref)
|
||||
return
|
||||
|
||||
elif storage_n == "env":
|
||||
log.info("secret_write_success storage=env ref=%s", secret_ref)
|
||||
return
|
||||
|
||||
elif storage_n == "windows_credential":
|
||||
_win_cred_write_impl(secret_ref, secret_value)
|
||||
log.info(
|
||||
"secret_write_success storage=windows_credential ref=%s",
|
||||
secret_ref[:48],
|
||||
)
|
||||
return
|
||||
|
||||
else:
|
||||
log.error("secret_write_failed unsupported storage=%s", storage)
|
||||
raise ValueError(f"Unsupported secret storage type: {storage}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def read_secret(storage: str, secret_ref: str) -> str:
|
||||
"""
|
||||
Read/retrieve a secret from the specified storage backend.
|
||||
Returns the secret value as a string.
|
||||
Raises ValueError on unsupported storage, missing ref, or read failure.
|
||||
"""
|
||||
log = get_skill_logger()
|
||||
log.info(
|
||||
"secret_resolve_attempt storage=%s ref_prefix=%s",
|
||||
storage,
|
||||
secret_ref[:16] if secret_ref else "none",
|
||||
)
|
||||
storage_n = storage.strip().lower()
|
||||
|
||||
if storage_n == "none":
|
||||
log.info("secret_resolve_success storage=none")
|
||||
return ""
|
||||
|
||||
elif storage_n == "env":
|
||||
val = os.environ.get(secret_ref)
|
||||
if val is None:
|
||||
log.error("secret_resolve_failed env_missing ref=%s", secret_ref)
|
||||
raise ValueError(
|
||||
f"Environment variable '{secret_ref}' is not set (SECRET_ENV_MISSING)"
|
||||
)
|
||||
log.info("secret_resolve_success storage=env ref=%s", secret_ref)
|
||||
return val
|
||||
|
||||
elif storage_n == "windows_credential":
|
||||
val = _win_cred_read_impl(secret_ref)
|
||||
log.info(
|
||||
"secret_resolve_success storage=windows_credential ref=%s",
|
||||
secret_ref[:48],
|
||||
)
|
||||
return val
|
||||
|
||||
else:
|
||||
log.error("secret_resolve_failed unsupported storage=%s", storage)
|
||||
raise ValueError(f"Unsupported secret storage type: {storage}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def delete_secret(storage: str, secret_ref: str) -> None:
|
||||
"""Delete a secret from the specified storage backend."""
|
||||
log = get_skill_logger()
|
||||
log.info(
|
||||
"secret_delete_attempt storage=%s ref_prefix=%s",
|
||||
storage,
|
||||
secret_ref[:16] if secret_ref else "none",
|
||||
)
|
||||
storage_n = storage.strip().lower()
|
||||
|
||||
if storage_n == "none":
|
||||
return
|
||||
elif storage_n == "env":
|
||||
log.info("secret_delete_env_nop ref=%s", secret_ref)
|
||||
return
|
||||
elif storage_n == "windows_credential":
|
||||
_win_cred_delete_impl(secret_ref)
|
||||
log.info(
|
||||
"secret_delete_success storage=windows_credential ref=%s",
|
||||
secret_ref[:48],
|
||||
)
|
||||
return
|
||||
else:
|
||||
raise ValueError(f"Unsupported secret storage type: {storage}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Windows Credential Manager implementation (ctypes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_WINCRED_TYPE_GENERIC = 1 # CRED_TYPE_GENERIC
|
||||
_WINCRED_PERSIST_LOCAL_MACHINE = 2 # CRED_PERSIST_LOCAL_MACHINE
|
||||
|
||||
|
||||
def _win_cred_available() -> bool:
|
||||
"""Check if Windows Credential Manager API is available."""
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
try:
|
||||
import ctypes # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def _win_cred_unavailable() -> OSError:
|
||||
return OSError(
|
||||
"Windows Credential Manager is not available on this platform "
|
||||
"(WINDOWS_CREDENTIAL_UNAVAILABLE)"
|
||||
)
|
||||
|
||||
|
||||
def _win_cred_write_impl(target: str, secret_value: str) -> None:
|
||||
if not _win_cred_available():
|
||||
raise _win_cred_unavailable()
|
||||
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
class CREDENTIALW(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("Flags", wintypes.DWORD),
|
||||
("Type", wintypes.DWORD),
|
||||
("TargetName", wintypes.LPCWSTR),
|
||||
("Comment", wintypes.LPCWSTR),
|
||||
("LastWritten", wintypes.FILETIME),
|
||||
("CredentialBlobSize", wintypes.DWORD),
|
||||
("CredentialBlob", ctypes.POINTER(ctypes.c_byte)),
|
||||
("Persist", wintypes.DWORD),
|
||||
("AttributeCount", wintypes.DWORD),
|
||||
("Attributes", ctypes.c_void_p),
|
||||
("TargetAlias", wintypes.LPCWSTR),
|
||||
("UserName", wintypes.LPCWSTR),
|
||||
]
|
||||
|
||||
secret_bytes = secret_value.encode("utf-16-le")
|
||||
blob_arr = (ctypes.c_byte * len(secret_bytes)).from_buffer_copy(secret_bytes)
|
||||
|
||||
cred = CREDENTIALW()
|
||||
cred.Type = _WINCRED_TYPE_GENERIC
|
||||
cred.TargetName = target
|
||||
cred.CredentialBlobSize = len(secret_bytes)
|
||||
cred.CredentialBlob = ctypes.cast(blob_arr, ctypes.POINTER(ctypes.c_byte))
|
||||
cred.Persist = _WINCRED_PERSIST_LOCAL_MACHINE
|
||||
cred.UserName = "account-manager"
|
||||
|
||||
advapi32 = ctypes.windll.advapi32
|
||||
if not advapi32.CredWriteW(ctypes.byref(cred), 0):
|
||||
err = ctypes.get_last_error()
|
||||
raise OSError(f"CredWriteW failed with error {err} (SECRET_WRITE_FAILED)")
|
||||
del blob_arr, cred
|
||||
|
||||
|
||||
def _win_cred_read_impl(target: str) -> str:
|
||||
if not _win_cred_available():
|
||||
raise _win_cred_unavailable()
|
||||
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
class CREDENTIALW(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("Flags", wintypes.DWORD),
|
||||
("Type", wintypes.DWORD),
|
||||
("TargetName", wintypes.LPCWSTR),
|
||||
("Comment", wintypes.LPCWSTR),
|
||||
("LastWritten", wintypes.FILETIME),
|
||||
("CredentialBlobSize", wintypes.DWORD),
|
||||
("CredentialBlob", ctypes.POINTER(ctypes.c_byte)),
|
||||
("Persist", wintypes.DWORD),
|
||||
("AttributeCount", wintypes.DWORD),
|
||||
("Attributes", ctypes.c_void_p),
|
||||
("TargetAlias", wintypes.LPCWSTR),
|
||||
("UserName", wintypes.LPCWSTR),
|
||||
]
|
||||
|
||||
PCREDENTIALW = ctypes.POINTER(CREDENTIALW)
|
||||
|
||||
advapi32 = ctypes.windll.advapi32
|
||||
advapi32.CredReadW.argtypes = [
|
||||
wintypes.LPCWSTR,
|
||||
wintypes.DWORD,
|
||||
wintypes.DWORD,
|
||||
ctypes.POINTER(PCREDENTIALW),
|
||||
]
|
||||
advapi32.CredReadW.restype = wintypes.BOOL
|
||||
advapi32.CredFree.argtypes = [ctypes.c_void_p]
|
||||
advapi32.CredFree.restype = None
|
||||
|
||||
ppcred = PCREDENTIALW()
|
||||
if not advapi32.CredReadW(target, _WINCRED_TYPE_GENERIC, 0, ctypes.byref(ppcred)):
|
||||
err = ctypes.get_last_error()
|
||||
if err == 1168:
|
||||
raise ValueError(
|
||||
f"Credential not found in Windows Credential Manager: target={target}"
|
||||
)
|
||||
raise OSError(f"CredReadW failed with error {err} (SECRET_READ_FAILED)")
|
||||
|
||||
try:
|
||||
cred = ppcred.contents
|
||||
blob_size = int(cred.CredentialBlobSize)
|
||||
if blob_size <= 0 or not cred.CredentialBlob:
|
||||
return ""
|
||||
raw = ctypes.string_at(cred.CredentialBlob, blob_size)
|
||||
return raw.decode("utf-16-le")
|
||||
finally:
|
||||
advapi32.CredFree(ppcred)
|
||||
|
||||
|
||||
def _win_cred_delete_impl(target: str) -> None:
|
||||
if not _win_cred_available():
|
||||
raise _win_cred_unavailable()
|
||||
|
||||
import ctypes
|
||||
|
||||
advapi32 = ctypes.windll.advapi32
|
||||
if not advapi32.CredDeleteW(target, _WINCRED_TYPE_GENERIC, 0):
|
||||
err = ctypes.get_last_error()
|
||||
if err != 1168:
|
||||
raise OSError(f"CredDeleteW failed with error {err}")
|
||||
@@ -1,4 +1,4 @@
|
||||
"""技能级常量与本地 CLI 调试环境注入(仅 CLI 入口显式调用 _apply_cli_local_dev_env 时生效)。"""
|
||||
"""技能级常量。本地 CLI 默认值见 jiangchang_skill_core.runtime_env(main.py 最早调用 apply_cli_local_defaults)。"""
|
||||
import os
|
||||
|
||||
# scripts/util/constants.py -> skill root = parents[2]
|
||||
@@ -7,29 +7,3 @@ BASE_DIR = _BASE
|
||||
SKILL_SLUG = "account-manager"
|
||||
# 与其它 OpenClaw 技能对齐:openclaw.skill.<slug_下划线>
|
||||
LOG_LOGGER_NAME = "openclaw.skill.account_manager"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 本地 CLI 调试:直接运行 python main.py 时,若未设置 JIANGCHANG_*,可自动注入下面默认值。
|
||||
# - 仅当 CLI 里调用了 _apply_cli_local_dev_env() 时生效;被其它脚本 import 不会改 os.environ。
|
||||
# - 不会覆盖宿主/终端已设置的 JIANGCHANG_DATA_ROOT、JIANGCHANG_USER_ID。
|
||||
# - 开启方式(二选一):
|
||||
# 1) 将 _ACCOUNT_MANAGER_CLI_LOCAL_DEV 改为 True;
|
||||
# 2) 或设置环境变量 JIANGCHANG_ACCOUNT_CLI_LOCAL_DEV=1(无需改代码)。
|
||||
# 合并/发版前请关闭开关,避免把个人机器路径带进生产制品。
|
||||
# ---------------------------------------------------------------------------
|
||||
_ACCOUNT_MANAGER_CLI_LOCAL_DEV = True
|
||||
_ACCOUNT_MANAGER_CLI_LOCAL_DATA_ROOT = r"D:\jiangchang-data"
|
||||
_ACCOUNT_MANAGER_CLI_LOCAL_USER_ID = "10032"
|
||||
|
||||
|
||||
def _apply_cli_local_dev_env() -> None:
|
||||
enabled = _ACCOUNT_MANAGER_CLI_LOCAL_DEV
|
||||
if not enabled:
|
||||
v = (os.getenv("JIANGCHANG_ACCOUNT_CLI_LOCAL_DEV") or "").strip().lower()
|
||||
enabled = v in ("1", "true", "yes", "on")
|
||||
if not enabled:
|
||||
return
|
||||
if not (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip():
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = _ACCOUNT_MANAGER_CLI_LOCAL_DATA_ROOT.strip()
|
||||
if not (os.getenv("JIANGCHANG_USER_ID") or "").strip():
|
||||
os.environ["JIANGCHANG_USER_ID"] = _ACCOUNT_MANAGER_CLI_LOCAL_USER_ID.strip()
|
||||
|
||||
@@ -1,69 +1,21 @@
|
||||
"""技能级文件日志。"""
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
"""Re-export unified logging (implementation: jiangchang_skill_core.unified_logging)."""
|
||||
|
||||
from util.constants import LOG_LOGGER_NAME, SKILL_SLUG
|
||||
from util.runtime_paths import get_skill_logs_dir
|
||||
from jiangchang_skill_core.unified_logging import (
|
||||
attach_unified_file_handler,
|
||||
ensure_trace_for_process,
|
||||
get_skill_log_file_path,
|
||||
get_skill_logger,
|
||||
get_unified_logs_dir,
|
||||
setup_skill_logging,
|
||||
subprocess_env_with_trace,
|
||||
)
|
||||
|
||||
|
||||
def _skill_log_file_path() -> str:
|
||||
"""主日志文件路径;可由 JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE 覆盖为绝对路径。"""
|
||||
override = (os.getenv("JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE") or "").strip()
|
||||
if override:
|
||||
parent = os.path.dirname(os.path.abspath(override))
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
return os.path.abspath(override)
|
||||
return os.path.join(get_skill_logs_dir(), f"{SKILL_SLUG}.log")
|
||||
|
||||
|
||||
def _log_level_from_env() -> int:
|
||||
v = (os.getenv("JIANGCHANG_LOG_LEVEL") or "INFO").strip().upper()
|
||||
return getattr(logging, v, None) or logging.INFO
|
||||
|
||||
|
||||
def get_skill_log_file_path() -> str:
|
||||
"""主日志文件绝对路径(与 get_skill_logger 写入文件一致)。"""
|
||||
return _skill_log_file_path()
|
||||
|
||||
|
||||
def get_skill_logger() -> logging.Logger:
|
||||
"""
|
||||
技能级日志:单文件按日切分(TimedRotatingFileHandler midnight),UTF-8。
|
||||
环境变量:JIANGCHANG_LOG_LEVEL(默认 INFO)、JIANGCHANG_LOG_TO_STDERR=1 时 WARNING+ 同步 stderr、
|
||||
JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE 覆盖日志文件路径。
|
||||
"""
|
||||
log = logging.getLogger(LOG_LOGGER_NAME)
|
||||
if log.handlers:
|
||||
return log
|
||||
log.setLevel(_log_level_from_env())
|
||||
path = _skill_log_file_path()
|
||||
fh = TimedRotatingFileHandler(
|
||||
path,
|
||||
when="midnight",
|
||||
interval=1,
|
||||
backupCount=30,
|
||||
encoding="utf-8",
|
||||
delay=True,
|
||||
)
|
||||
fh.setFormatter(
|
||||
logging.Formatter(
|
||||
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
||||
datefmt="%Y-%m-%dT%H:%M:%S",
|
||||
)
|
||||
)
|
||||
log.addHandler(fh)
|
||||
if (os.getenv("JIANGCHANG_LOG_TO_STDERR") or "").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
):
|
||||
sh = logging.StreamHandler(sys.stderr)
|
||||
sh.setLevel(logging.WARNING)
|
||||
sh.setFormatter(fh.formatter)
|
||||
log.addHandler(sh)
|
||||
log.propagate = False
|
||||
return log
|
||||
__all__ = [
|
||||
"attach_unified_file_handler",
|
||||
"ensure_trace_for_process",
|
||||
"get_skill_log_file_path",
|
||||
"get_skill_logger",
|
||||
"get_unified_logs_dir",
|
||||
"setup_skill_logging",
|
||||
"subprocess_env_with_trace",
|
||||
]
|
||||
|
||||
@@ -1,139 +1,341 @@
|
||||
"""平台配置、别名解析、手机号校验。"""
|
||||
import re
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Platform definitions and resolution.
|
||||
|
||||
# 平台唯一配置表:只改这里即可。键 = 入库的 platform;label = 帮助/提示里的展示名;
|
||||
# prefix = 默认账号名「{prefix}{序号}号」,省略时等于 label;
|
||||
# aliases = 额外 CLI 称呼(英文键与 label 已自动参与解析,不必重复写)。
|
||||
# 登录检测:仅看页面 DOM。匹配 anchor 的标签页上出现「未登录」选择器则未登录;否则视为已登录。
|
||||
# - _LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS:通用「登录」按钮/链接等(可被 login_skip_generic_logged_out_dom 关闭)。
|
||||
# - login_logged_out_selectors:按平台追加;误判时也可用 login_skip_generic_logged_out_dom + 仅平台自选。
|
||||
PLATFORMS = {
|
||||
"sohu": {
|
||||
"url": "https://mp.sohu.com",
|
||||
"label": "搜狐号",
|
||||
"prefix": "搜狐",
|
||||
"aliases": ["搜狐"],
|
||||
Central authoritative registry for all platforms (logistics, LLM, content, etc.).
|
||||
Import and use PLATFORMS dict, resolve_platform_key(), and seed_platforms().
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from util.logging_config import get_skill_logger
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform specification fields:
|
||||
# display_name – Human-readable name (e.g. "Maersk")
|
||||
# domain – Business domain: logistics / llm / content / ecommerce / generic
|
||||
# provider_code – Supplier code (logistics); can be same as key
|
||||
# default_url – Default entry URL (empty string if none)
|
||||
# aliases – List of name aliases (Chinese/English, for CLI resolution)
|
||||
# capabilities – Dict of capability flags:
|
||||
# {"web": bool, "api_key": bool, "rpa": bool}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PLATFORMS: dict[str, dict[str, Any]] = {
|
||||
# =========================================================================
|
||||
# Logistics platforms
|
||||
# =========================================================================
|
||||
"maersk": {
|
||||
"display_name": "Maersk",
|
||||
"domain": "logistics",
|
||||
"provider_code": "maersk",
|
||||
"default_url": "https://www.maersk.com",
|
||||
"aliases": ["马士基", "maersk", "Maersk"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": True},
|
||||
},
|
||||
"toutiao": {
|
||||
"url": "https://mp.toutiao.com/",
|
||||
"label": "头条号",
|
||||
"prefix": "头条",
|
||||
"aliases": ["头条"],
|
||||
"cosco": {
|
||||
"display_name": "COSCO",
|
||||
"domain": "logistics",
|
||||
"provider_code": "cosco",
|
||||
"default_url": "",
|
||||
"aliases": ["中远海运", "COSCO", "cosco"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": True},
|
||||
},
|
||||
"zhihu": {
|
||||
"url": "https://www.zhihu.com",
|
||||
"label": "知乎",
|
||||
"aliases": ["知乎号"],
|
||||
"msc": {
|
||||
"display_name": "MSC",
|
||||
"domain": "logistics",
|
||||
"provider_code": "msc",
|
||||
"default_url": "",
|
||||
"aliases": ["MSC", "地中海航运"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": True},
|
||||
},
|
||||
"wechat": {
|
||||
"url": "https://mp.weixin.qq.com",
|
||||
"label": "微信公众号",
|
||||
"prefix": "微信",
|
||||
"aliases": ["公众号", "微信"],
|
||||
"cma_cgm": {
|
||||
"display_name": "CMA CGM",
|
||||
"domain": "logistics",
|
||||
"provider_code": "cma_cgm",
|
||||
"default_url": "",
|
||||
"aliases": ["达飞", "CMA CGM", "cma cgm"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": True},
|
||||
},
|
||||
"kimi": {
|
||||
"url": "https://kimi.moonshot.cn",
|
||||
"label": "Kimi",
|
||||
"aliases": ["月之暗面"],
|
||||
"evergreen": {
|
||||
"display_name": "Evergreen",
|
||||
"domain": "logistics",
|
||||
"provider_code": "evergreen",
|
||||
"default_url": "",
|
||||
"aliases": ["长荣", "Evergreen", "evergreen"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": True},
|
||||
},
|
||||
"cargo_wise": {
|
||||
"display_name": "CargoWise",
|
||||
"domain": "logistics",
|
||||
"provider_code": "cargo_wise",
|
||||
"default_url": "",
|
||||
"aliases": ["CargoWise", "Cargo Wise", "cargo wise"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": True},
|
||||
},
|
||||
"freightos": {
|
||||
"display_name": "Freightos",
|
||||
"domain": "logistics",
|
||||
"provider_code": "freightos",
|
||||
"default_url": "",
|
||||
"aliases": ["Freightos", "freightos"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"webcargo": {
|
||||
"display_name": "WebCargo",
|
||||
"domain": "logistics",
|
||||
"provider_code": "webcargo",
|
||||
"default_url": "",
|
||||
"aliases": ["WebCargo", "web cargo"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"sinotrans": {
|
||||
"display_name": "Sinotrans",
|
||||
"domain": "logistics",
|
||||
"provider_code": "sinotrans",
|
||||
"default_url": "",
|
||||
"aliases": ["中外运", "Sinotrans", "sinotrans"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": True},
|
||||
},
|
||||
# =========================================================================
|
||||
# LLM / AI platforms
|
||||
# =========================================================================
|
||||
"deepseek": {
|
||||
"url": "https://chat.deepseek.com",
|
||||
"label": "DeepSeek",
|
||||
"display_name": "DeepSeek",
|
||||
"domain": "llm",
|
||||
"provider_code": "deepseek",
|
||||
"default_url": "https://chat.deepseek.com",
|
||||
"aliases": ["DeepSeek", "deepseek"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"doubao": {
|
||||
"url": "https://www.doubao.com",
|
||||
"label": "豆包",
|
||||
"display_name": "Doubao",
|
||||
"domain": "llm",
|
||||
"provider_code": "doubao",
|
||||
"default_url": "https://www.doubao.com",
|
||||
"aliases": ["豆包", "Doubao"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"kimi": {
|
||||
"display_name": "Kimi",
|
||||
"domain": "llm",
|
||||
"provider_code": "kimi",
|
||||
"default_url": "https://kimi.moonshot.cn",
|
||||
"aliases": ["Kimi", "月之暗面"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"qianwen": {
|
||||
"url": "https://tongyi.aliyun.com",
|
||||
"label": "通义千问",
|
||||
"prefix": "通义",
|
||||
"aliases": ["通义", "千问"],
|
||||
"display_name": "Qianwen",
|
||||
"domain": "llm",
|
||||
"provider_code": "qianwen",
|
||||
"default_url": "https://tongyi.aliyun.com",
|
||||
"aliases": ["通义千问", "通义", "千问", "Qianwen"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"yiyan": {
|
||||
"url": "https://yiyan.baidu.com",
|
||||
"label": "文心一言",
|
||||
"prefix": "文心",
|
||||
"aliases": ["文心", "一言"],
|
||||
"display_name": "YiYan",
|
||||
"domain": "llm",
|
||||
"provider_code": "yiyan",
|
||||
"default_url": "https://yiyan.baidu.com",
|
||||
"aliases": ["文心一言", "文心", "一言", "YiYan"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"yuanbao": {
|
||||
"url": "https://yuanbao.tencent.com",
|
||||
"label": "腾讯元宝",
|
||||
"prefix": "元宝",
|
||||
"aliases": ["元宝"],
|
||||
"display_name": "Yuanbao",
|
||||
"domain": "llm",
|
||||
"provider_code": "yuanbao",
|
||||
"default_url": "https://yuanbao.tencent.com",
|
||||
"aliases": ["腾讯元宝", "元宝", "Yuanbao"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"gemini": {
|
||||
"display_name": "Gemini",
|
||||
"domain": "llm",
|
||||
"provider_code": "gemini",
|
||||
"default_url": "https://gemini.google.com",
|
||||
"aliases": ["Gemini", "谷歌Gemini", "Google Gemini", "Bard"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"minimax": {
|
||||
"display_name": "MiniMax",
|
||||
"domain": "llm",
|
||||
"provider_code": "minimax",
|
||||
"default_url": "",
|
||||
"aliases": ["MiniMax", "minimax"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
# =========================================================================
|
||||
# Content platforms (carried forward from v1)
|
||||
# =========================================================================
|
||||
"sohu": {
|
||||
"display_name": "搜狐号",
|
||||
"domain": "content",
|
||||
"provider_code": "sohu",
|
||||
"default_url": "https://mp.sohu.com",
|
||||
"aliases": ["搜狐", "搜狐号"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
"toutiao": {
|
||||
"display_name": "头条号",
|
||||
"domain": "content",
|
||||
"provider_code": "toutiao",
|
||||
"default_url": "https://mp.toutiao.com/",
|
||||
"aliases": ["头条", "头条号"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
"zhihu": {
|
||||
"display_name": "知乎",
|
||||
"domain": "content",
|
||||
"provider_code": "zhihu",
|
||||
"default_url": "https://www.zhihu.com",
|
||||
"aliases": ["知乎", "知乎号"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
"wechat": {
|
||||
"display_name": "微信公众号",
|
||||
"domain": "content",
|
||||
"provider_code": "wechat",
|
||||
"default_url": "https://mp.weixin.qq.com",
|
||||
"aliases": ["公众号", "微信", "微信公众号"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
}
|
||||
|
||||
# 未登录页共性:主行动点含「登录」——submit 按钮、Element 主按钮、Semi 文案、标题、通栏主按钮等,Playwright :has-text 可覆盖多数站点。
|
||||
_LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS = (
|
||||
'button:has-text("登录")',
|
||||
'role=button[name="登录"]',
|
||||
'a:has-text("登录")',
|
||||
'h4:has-text("登录")',
|
||||
'span.semi-button-content:has-text("登录")',
|
||||
)
|
||||
|
||||
|
||||
def _build_platform_derived():
|
||||
urls = {}
|
||||
name_prefix = {}
|
||||
primary_cn = {}
|
||||
alias_to_key = {}
|
||||
|
||||
def _register_alias(alias: str, key: str) -> None:
|
||||
if not alias or not str(alias).strip():
|
||||
return
|
||||
a = str(alias).strip()
|
||||
if a not in alias_to_key:
|
||||
alias_to_key[a] = key
|
||||
lo = a.lower()
|
||||
if lo not in alias_to_key:
|
||||
alias_to_key[lo] = key
|
||||
# ---------------------------------------------------------------------------
|
||||
# Derived alias map
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_alias_map() -> dict[str, str]:
|
||||
"""Build a case-insensitive flat alias -> key map."""
|
||||
m: dict[str, str] = {}
|
||||
for key, spec in PLATFORMS.items():
|
||||
urls[key] = spec["url"]
|
||||
primary_cn[key] = spec["label"]
|
||||
name_prefix[key] = (spec.get("prefix") or spec["label"]).strip() or key
|
||||
_register_alias(key, key)
|
||||
_register_alias(spec["label"], key)
|
||||
for a in spec.get("aliases") or []:
|
||||
_register_alias(a, key)
|
||||
|
||||
return urls, name_prefix, primary_cn, alias_to_key
|
||||
# Register the key itself (lowercase)
|
||||
m[key.lower()] = key
|
||||
# Register all aliases (original + lowercase)
|
||||
for alias in [spec["display_name"]] + spec.get("aliases", []):
|
||||
a = str(alias).strip()
|
||||
if a:
|
||||
m[a] = key
|
||||
m[a.lower()] = key
|
||||
return m
|
||||
|
||||
|
||||
PLATFORM_URLS, _PLATFORM_NAME_CN, _PLATFORM_PRIMARY_CN, _PLATFORM_ALIAS_TO_KEY = _build_platform_derived()
|
||||
_PLATFORM_ALIAS_MAP = _build_alias_map()
|
||||
|
||||
|
||||
def resolve_platform_key(name: str):
|
||||
"""将用户输入解析为内部 platform 键;无法识别返回 None。"""
|
||||
if name is None:
|
||||
def resolve_platform_key(name: str) -> Optional[str]:
|
||||
"""
|
||||
Resolve user input to a canonical platform key.
|
||||
Supports: key, display_name, any alias. Case-insensitive.
|
||||
Returns None if unrecognized.
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
s = str(name).strip()
|
||||
if not s:
|
||||
return None
|
||||
sl = s.lower()
|
||||
if sl in PLATFORM_URLS:
|
||||
# Direct key match
|
||||
if sl in PLATFORMS:
|
||||
return sl
|
||||
return _PLATFORM_ALIAS_TO_KEY.get(s)
|
||||
# Alias map
|
||||
if sl in _PLATFORM_ALIAS_MAP:
|
||||
return _PLATFORM_ALIAS_MAP[sl]
|
||||
if s in _PLATFORM_ALIAS_MAP:
|
||||
return _PLATFORM_ALIAS_MAP[s]
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_phone_digits(phone: str) -> str:
|
||||
"""从输入中提取数字串,供号段规则校验与入库去重(不对外承诺可随意夹杂符号)。"""
|
||||
d = re.sub(r"\D", "", (phone or "").strip())
|
||||
if d.startswith("86") and len(d) >= 13:
|
||||
d = d[2:]
|
||||
return d
|
||||
def get_platform(key: str) -> Optional[dict[str, Any]]:
|
||||
"""Return the full platform spec dict for a validated key."""
|
||||
return PLATFORMS.get(key)
|
||||
|
||||
|
||||
def _is_valid_cn_mobile11(digits: str) -> bool:
|
||||
"""中国大陆 11 位手机号:1 开头,第二位 3–9,共 11 位数字。"""
|
||||
return bool(re.fullmatch(r"1[3-9]\d{9}", digits or ""))
|
||||
def list_platforms(domain: Optional[str] = None) -> list[dict[str, Any]]:
|
||||
"""List all platforms, optionally filtered by domain.
|
||||
Returns a list of dicts with metadata."""
|
||||
result = []
|
||||
for key, spec in PLATFORMS.items():
|
||||
if domain and spec["domain"] != domain:
|
||||
continue
|
||||
result.append({
|
||||
"platform_key": key,
|
||||
"display_name": spec["display_name"],
|
||||
"domain": spec["domain"],
|
||||
"provider_code": spec.get("provider_code"),
|
||||
"default_url": spec.get("default_url", ""),
|
||||
"aliases": spec.get("aliases", []),
|
||||
"capabilities": spec.get("capabilities", {}),
|
||||
"enabled": True,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def seed_platforms(conn) -> None:
|
||||
"""
|
||||
Upsert all platform definitions into the 'platforms' table.
|
||||
Must be called after init_db() in the same connection lifecycle.
|
||||
"""
|
||||
log = get_skill_logger()
|
||||
log.info("platform_seed_start")
|
||||
cur = conn.cursor()
|
||||
now = int(time.time())
|
||||
upsert_count = 0
|
||||
for key, spec in PLATFORMS.items():
|
||||
aliases_json = json.dumps(spec.get("aliases", []), ensure_ascii=False)
|
||||
capabilities_json = json.dumps(spec.get("capabilities", {}), ensure_ascii=False)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO platforms (platform_key, display_name, domain, provider_code,
|
||||
default_url, aliases_json, capabilities_json,
|
||||
enabled, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
|
||||
ON CONFLICT(platform_key) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
domain = excluded.domain,
|
||||
provider_code = excluded.provider_code,
|
||||
default_url = excluded.default_url,
|
||||
aliases_json = excluded.aliases_json,
|
||||
capabilities_json = excluded.capabilities_json,
|
||||
enabled = 1,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
key,
|
||||
spec["display_name"],
|
||||
spec["domain"],
|
||||
spec.get("provider_code"),
|
||||
spec.get("default_url", ""),
|
||||
aliases_json,
|
||||
capabilities_json,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
upsert_count += 1
|
||||
conn.commit()
|
||||
log.info("platform_seed_done count=%s", upsert_count)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy compatibility helpers (for old account_service v1 code references)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Kept for backward compatibility so old imports don't break immediately.
|
||||
# New code should use PLATFORMS dict directly and resolve_platform_key().
|
||||
_PLATFORM_PRIMARY_CN: dict[str, str] = {
|
||||
k: spec["display_name"] for k, spec in PLATFORMS.items()
|
||||
}
|
||||
PLATFORM_URLS: dict[str, str] = {
|
||||
k: (spec.get("default_url") or "") for k, spec in PLATFORMS.items()
|
||||
}
|
||||
|
||||
|
||||
def _platform_list_cn_for_help() -> str:
|
||||
"""帮助文案:一行中文展示名(不重复内部键)。"""
|
||||
parts = []
|
||||
for k in sorted(PLATFORM_URLS.keys()):
|
||||
parts.append(_PLATFORM_PRIMARY_CN.get(k, k))
|
||||
return "、".join(parts)
|
||||
"""帮助文案:一行展示名,供 CLI 用法提示。"""
|
||||
return "、".join(
|
||||
spec["display_name"] for _k, spec in sorted(
|
||||
PLATFORMS.items(), key=lambda x: x[1]["display_name"]
|
||||
)
|
||||
)
|
||||
|
||||
40
scripts/util/playwright_stealth.py
Normal file
40
scripts/util/playwright_stealth.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
淡化 Playwright 启动时的自动化指纹,减轻 Google 账号页「此浏览器或应用可能不安全」类拦截。
|
||||
|
||||
关闭:环境变量 OPENCLAW_PLAYWRIGHT_STEALTH=0(或 false/off/no)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
STEALTH_INIT_SCRIPT = """
|
||||
(() => {
|
||||
try {
|
||||
Object.defineProperty(navigator, "webdriver", { get: () => undefined });
|
||||
} catch (e) {}
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
def stealth_enabled() -> bool:
|
||||
v = (os.environ.get("OPENCLAW_PLAYWRIGHT_STEALTH") or "1").strip().lower()
|
||||
return v not in ("0", "false", "no", "off")
|
||||
|
||||
|
||||
def persistent_context_launch_parts(
|
||||
*,
|
||||
extra_args: list[str] | None = None,
|
||||
) -> tuple[list[str], list[str] | None]:
|
||||
"""
|
||||
返回 (args, ignore_default_args)。
|
||||
ignore_default_args 为 None 时不应传给 launch_persistent_context。
|
||||
"""
|
||||
args = ["--start-maximized"]
|
||||
if extra_args:
|
||||
args = args + list(extra_args)
|
||||
if not stealth_enabled():
|
||||
return args, None
|
||||
if "--disable-blink-features=AutomationControlled" not in args:
|
||||
args.append("--disable-blink-features=AutomationControlled")
|
||||
return args, ["--enable-automation"]
|
||||
@@ -1,46 +1,28 @@
|
||||
"""数据目录、profile 路径、CLI 路径调试输出。"""
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Data directory, profile path, CLI path debugging output.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from jiangchang_skill_core.runtime_env import get_data_root, get_user_id
|
||||
from util.constants import SKILL_SLUG
|
||||
from util.platforms import _PLATFORM_PRIMARY_CN
|
||||
|
||||
|
||||
def get_data_root():
|
||||
env = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip()
|
||||
if env:
|
||||
return env
|
||||
if sys.platform == "win32":
|
||||
return r"D:\jiangchang-data"
|
||||
return os.path.join(os.path.expanduser("~"), ".jiangchang-data")
|
||||
|
||||
|
||||
def get_user_id():
|
||||
uid = (os.getenv("JIANGCHANG_USER_ID") or "").strip()
|
||||
return uid or "_anon"
|
||||
|
||||
|
||||
def get_skill_data_dir():
|
||||
def get_skill_data_dir() -> str:
|
||||
path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def get_skill_logs_dir() -> str:
|
||||
"""{DATA_ROOT}/{USER_ID}/account-manager/logs/"""
|
||||
path = os.path.join(get_skill_data_dir(), "logs")
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def get_db_path():
|
||||
def get_db_path() -> str:
|
||||
return os.path.join(get_skill_data_dir(), "account-manager.db")
|
||||
|
||||
|
||||
def _runtime_paths_debug_text():
|
||||
"""供排查「为何 list 没数据」:终端未注入环境变量时会落到 _anon 等默认目录,与网关里用的库不是同一个文件。"""
|
||||
def _runtime_paths_debug_text() -> str:
|
||||
"""For debugging: print current data path info to stderr."""
|
||||
env_root = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip() or "(未设置)"
|
||||
env_uid = (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "(未设置→使用 _anon)"
|
||||
return (
|
||||
@@ -53,8 +35,8 @@ def _runtime_paths_debug_text():
|
||||
)
|
||||
|
||||
|
||||
def _maybe_print_paths_debug_cli():
|
||||
"""设置 JIANGCHANG_ACCOUNT_DEBUG_PATHS=1 时,每次执行子命令前在 stderr 打印路径。"""
|
||||
def _maybe_print_paths_debug_cli() -> None:
|
||||
"""Set JIANGCHANG_ACCOUNT_DEBUG_PATHS=1 to print paths in stderr."""
|
||||
v = (os.getenv("JIANGCHANG_ACCOUNT_DEBUG_PATHS") or "").strip().lower()
|
||||
if v in ("1", "true", "yes", "on"):
|
||||
print(_runtime_paths_debug_text(), file=sys.stderr)
|
||||
@@ -69,7 +51,7 @@ _WIN_RESERVED_NAMES = frozenset(
|
||||
|
||||
|
||||
def _fs_safe_segment(name: str, fallback: str) -> str:
|
||||
"""单级目录名:去掉 Windows 非法字符,避免末尾点/空格;空则用 fallback。"""
|
||||
"""Single directory segment: strip Windows-unsafe chars; empty -> fallback."""
|
||||
t = _WIN_FS_FORBIDDEN.sub("_", (name or "").strip())
|
||||
t = t.rstrip(" .")
|
||||
if not t:
|
||||
@@ -79,22 +61,33 @@ def _fs_safe_segment(name: str, fallback: str) -> str:
|
||||
return t
|
||||
|
||||
|
||||
def get_default_profile_dir(
|
||||
platform_key: str, phone: Optional[str], account_id: Optional[int] = None
|
||||
def get_default_profile_dir_for_web(
|
||||
platform_key: str,
|
||||
label: str,
|
||||
login_id: Optional[str] = None,
|
||||
domain: str = "generic",
|
||||
) -> str:
|
||||
"""
|
||||
默认可读路径:profiles/<平台展示名>/<手机号>/(便于在资源管理器中辨认)。
|
||||
无手机号时退化为 profiles/<平台展示名>/no_phone_<id>/。
|
||||
Generate a browser profile directory path.
|
||||
|
||||
Structure:
|
||||
{DATA_ROOT}/{USER_ID}/account-manager/profiles/{domain}/{platform_key}/{safe_label}[_{login_id_suffix]/
|
||||
|
||||
This keeps profiles organized by domain and platform for easy
|
||||
identification in file explorer.
|
||||
"""
|
||||
label = _PLATFORM_PRIMARY_CN.get(platform_key, platform_key or "account")
|
||||
label_seg = _fs_safe_segment(label, _fs_safe_segment(platform_key or "", "platform"))
|
||||
ph = (phone or "").strip()
|
||||
if ph:
|
||||
phone_seg = _fs_safe_segment(ph, f"id_{account_id}" if account_id is not None else "phone")
|
||||
else:
|
||||
phone_seg = _fs_safe_segment(
|
||||
"", f"no_phone_{account_id}" if account_id is not None else "no_phone"
|
||||
)
|
||||
path = os.path.join(get_skill_data_dir(), "profiles", label_seg, phone_seg)
|
||||
label_seg = _fs_safe_segment(label, platform_key)
|
||||
login_suffix = ""
|
||||
if login_id:
|
||||
safe_login = _fs_safe_segment(login_id, "")
|
||||
if safe_login:
|
||||
login_suffix = f"_{safe_login[:16]}"
|
||||
path = os.path.join(
|
||||
get_skill_data_dir(),
|
||||
"profiles",
|
||||
_fs_safe_segment(domain, "generic"),
|
||||
_fs_safe_segment(platform_key, "unknown"),
|
||||
f"{label_seg}{login_suffix}",
|
||||
)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# account-manager tests
|
||||
49
tests/conftest.py
Normal file
49
tests/conftest.py
Normal file
@@ -0,0 +1,49 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Test fixtures for account-manager tests.
|
||||
|
||||
All tests use an isolated temporary database under a fake JIANGCHANG_DATA_ROOT
|
||||
so they never touch real user data.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
# Ensure scripts/ is on the path
|
||||
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
|
||||
if _scripts not in sys.path:
|
||||
sys.path.insert(0, _scripts)
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def fake_env():
|
||||
"""Set up a fake JIANGCHANG_DATA_ROOT / JIANGCHANG_USER_ID for the session."""
|
||||
tmp = tempfile.mkdtemp(prefix="account_manager_test_")
|
||||
data_root = os.path.join(tmp, "claw-data")
|
||||
os.makedirs(data_root)
|
||||
fake_uid = "99999"
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = data_root
|
||||
os.environ["JIANGCHANG_USER_ID"] = fake_uid
|
||||
yield {"data_root": data_root, "user_id": fake_uid, "tmp_dir": tmp}
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def clean_db(fake_env):
|
||||
"""Each test gets a fresh in-memory DB (init_db uses the env vars set by fake_env)."""
|
||||
# Import after env vars are set
|
||||
from db.connection import get_conn, init_db
|
||||
from util.constants import SKILL_SLUG
|
||||
from util.logging_config import setup_skill_logging
|
||||
|
||||
setup_skill_logging(SKILL_SLUG, "openclaw.skill.account_manager_test")
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
conn.execute("DELETE FROM account_leases")
|
||||
conn.execute("DELETE FROM credentials")
|
||||
conn.execute("DELETE FROM accounts")
|
||||
conn.commit()
|
||||
yield conn
|
||||
conn.close()
|
||||
21
tests/run_tests.py
Normal file
21
tests/run_tests.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Test runner for account-manager.
|
||||
|
||||
Run:
|
||||
pytest tests/ -v
|
||||
python tests/run_tests.py -v
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
_script_dir = os.path.join(os.path.dirname(__file__), "..", "scripts")
|
||||
if _script_dir not in sys.path:
|
||||
sys.path.insert(0, _script_dir)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
|
||||
tests_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
args = [tests_dir, "-v"] + sys.argv[1:]
|
||||
sys.exit(pytest.main(args))
|
||||
297
tests/test_account_service.py
Normal file
297
tests/test_account_service.py
Normal file
@@ -0,0 +1,297 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Tests for account CRUD operations and pick-web filtering.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
class TestAccountAddWeb:
|
||||
"""account add-web creates records correctly without requiring phone numbers."""
|
||||
|
||||
def test_add_web_email_login_id(self, clean_db):
|
||||
"""Email login_id should be accepted (no 11-digit phone restriction)."""
|
||||
from service.account_service import cmd_account_add_web
|
||||
|
||||
cmd_account_add_web(
|
||||
platform_input="maersk",
|
||||
login_id="demo@maersk.com",
|
||||
login_id_type="email",
|
||||
label="Maersk sim test",
|
||||
tenant_id="tenant-demo",
|
||||
environment="simulator",
|
||||
role="booking",
|
||||
provider_code=None,
|
||||
url=None,
|
||||
extra_json_str=None,
|
||||
profile_dir_override=None,
|
||||
)
|
||||
# Should succeed with JSON output
|
||||
from db.accounts_repo import find_accounts
|
||||
accs = find_accounts(platform_key="maersk")
|
||||
assert len(accs) == 1
|
||||
assert accs[0]["login_id"] == "demo@maersk.com"
|
||||
assert accs[0]["login_id_type"] == "email"
|
||||
assert accs[0]["environment"] == "simulator"
|
||||
assert accs[0]["role"] == "booking"
|
||||
assert accs[0]["tenant_id"] == "tenant-demo"
|
||||
assert accs[0]["status"] == "active"
|
||||
assert accs[0]["platform_key"] == "maersk"
|
||||
|
||||
def test_add_web_no_login_id(self, clean_db):
|
||||
"""login_id can be None (e.g. for browser_profile-only accounts)."""
|
||||
from service.account_service import cmd_account_add_web
|
||||
|
||||
cmd_account_add_web(
|
||||
platform_input="deepseek",
|
||||
login_id=None,
|
||||
login_id_type="unknown",
|
||||
label="DeepSeek prod",
|
||||
tenant_id=None,
|
||||
environment="production",
|
||||
role="api",
|
||||
provider_code=None,
|
||||
url=None,
|
||||
extra_json_str=None,
|
||||
profile_dir_override=None,
|
||||
)
|
||||
from db.accounts_repo import find_accounts
|
||||
accs = find_accounts(platform_key="deepseek")
|
||||
assert len(accs) == 1
|
||||
assert accs[0]["login_id"] == ""
|
||||
|
||||
def test_add_web_profile_dir_created(self, clean_db, fake_env):
|
||||
"""profile_dir should be auto-created under fake_env data root."""
|
||||
from service.account_service import cmd_account_add_web
|
||||
|
||||
cmd_account_add_web(
|
||||
platform_input="maersk",
|
||||
login_id="test@example.com",
|
||||
login_id_type="email",
|
||||
label="Profile dir test",
|
||||
tenant_id="tenant-test",
|
||||
environment="production",
|
||||
role="booking",
|
||||
provider_code=None,
|
||||
url=None,
|
||||
extra_json_str=None,
|
||||
profile_dir_override=None,
|
||||
)
|
||||
from db.accounts_repo import find_accounts
|
||||
accs = find_accounts(platform_key="maersk")
|
||||
assert len(accs) == 1
|
||||
profile_dir = accs[0]["profile_dir"]
|
||||
assert profile_dir, "profile_dir should be non-empty"
|
||||
assert os.path.isdir(profile_dir), f"profile_dir should be created: {profile_dir}"
|
||||
# The path should be under the fake data root
|
||||
assert profile_dir.startswith(fake_env["data_root"]), \
|
||||
f"profile_dir should be under fake data root: {profile_dir}"
|
||||
|
||||
def test_add_web_extra_json(self, clean_db):
|
||||
"""extra_json field is stored and retrieved correctly."""
|
||||
from service.account_service import cmd_account_add_web
|
||||
|
||||
extra = {"api_version": "v1", "timeout": 30}
|
||||
cmd_account_add_web(
|
||||
platform_input="cma_cgm",
|
||||
login_id="cma@test.com",
|
||||
login_id_type="email",
|
||||
label="CMA CGM test",
|
||||
tenant_id="tenant-cma",
|
||||
environment="production",
|
||||
role="documentation",
|
||||
provider_code=None,
|
||||
url=None,
|
||||
extra_json_str=json.dumps(extra),
|
||||
profile_dir_override=None,
|
||||
)
|
||||
from db.accounts_repo import find_accounts
|
||||
accs = find_accounts(platform_key="cma_cgm")
|
||||
assert len(accs) == 1
|
||||
raw_ex = accs[0].get("extra_json")
|
||||
extra_parsed = raw_ex if isinstance(raw_ex, dict) else json.loads(raw_ex or "{}")
|
||||
assert extra_parsed["api_version"] == "v1"
|
||||
assert extra_parsed["timeout"] == 30
|
||||
|
||||
|
||||
class TestAccountGetList:
|
||||
"""account get/list operations."""
|
||||
|
||||
def test_get_account_found(self, clean_db):
|
||||
"""account get returns JSON with correct account fields."""
|
||||
from service.account_service import cmd_account_add_web, cmd_account_get
|
||||
import io
|
||||
import sys
|
||||
|
||||
# Add account first
|
||||
cmd_account_add_web(
|
||||
platform_input="evergreen",
|
||||
login_id="ship@evergreen.com",
|
||||
login_id_type="email",
|
||||
label="Evergreen booking",
|
||||
tenant_id="tenant-evg",
|
||||
environment="production",
|
||||
role="booking",
|
||||
provider_code=None,
|
||||
url=None,
|
||||
extra_json_str=None,
|
||||
profile_dir_override=None,
|
||||
)
|
||||
accs = find_accounts_()
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_account_get(accs[0]["id"])
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
output = captured.getvalue()
|
||||
data = json.loads(output)
|
||||
assert data["platform_key"] == "evergreen"
|
||||
assert data["login_id"] == "ship@evergreen.com"
|
||||
|
||||
def test_get_account_not_found(self, clean_db):
|
||||
"""account get for unknown id returns ERROR:ACCOUNT_NOT_FOUND."""
|
||||
from service.account_service import cmd_account_get
|
||||
import io
|
||||
import sys
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_account_get(9999)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
output = captured.getvalue()
|
||||
assert "ERROR:ACCOUNT_NOT_FOUND" in output
|
||||
|
||||
def test_list_platform_filter(self, clean_db):
|
||||
"""account list --platform filters by platform."""
|
||||
from service.account_service import cmd_account_add_web, cmd_account_list
|
||||
import io
|
||||
import sys
|
||||
|
||||
cmd_account_add_web("maersk", "a@maersk.com", "email", "m", "t1", "prod", "booking", None, None, None, None)
|
||||
cmd_account_add_web("maersk", "b@maersk.com", "email", "m2", "t1", "prod", "documentation", None, None, None, None)
|
||||
cmd_account_add_web("cma_cgm", "c@cma.com", "email", "c", "t2", "prod", "booking", None, None, None, None)
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_account_list(platform_input="maersk")
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
output = captured.getvalue()
|
||||
accs = json.loads(output)
|
||||
assert len(accs) == 2
|
||||
assert all(a["platform_key"] == "maersk" for a in accs)
|
||||
|
||||
|
||||
def find_accounts_():
|
||||
from db.accounts_repo import find_accounts
|
||||
return find_accounts(limit=200)
|
||||
|
||||
|
||||
class TestPickWebFiltering:
|
||||
"""pick-web filtering by environment / role / tenant_id."""
|
||||
|
||||
def _add(self, platform, login, label, tenant, env, role):
|
||||
from service.account_service import cmd_account_add_web
|
||||
cmd_account_add_web(
|
||||
platform, login, "email", label, tenant, env, role,
|
||||
None, None, None, None,
|
||||
)
|
||||
|
||||
def test_filter_environment(self, clean_db):
|
||||
"""pick-web --environment simulator selects only simulator account."""
|
||||
self._add("maersk", "sim@maersk.com", "S", "t1", "simulator", "booking")
|
||||
self._add("maersk", "prod@maersk.com", "P", "t1", "production", "booking")
|
||||
self._add("maersk", "prod2@maersk.com", "P2", "t1", "production", "documentation")
|
||||
|
||||
from service.account_service import cmd_account_pick_web
|
||||
import io, sys
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_account_pick_web("maersk", environment="simulator", tenant_id=None, role=None, do_lease=False)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert result["login_id"] == "sim@maersk.com"
|
||||
assert result["environment"] == "simulator"
|
||||
|
||||
def test_filter_role(self, clean_db):
|
||||
"""pick-web --role booking selects only booking role."""
|
||||
self._add("maersk", "a@maersk.com", "A", "t1", "simulator", "booking")
|
||||
self._add("maersk", "b@maersk.com", "B", "t1", "simulator", "documentation")
|
||||
|
||||
from service.account_service import cmd_account_pick_web
|
||||
import io, sys
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_account_pick_web("maersk", environment=None, tenant_id=None, role="documentation", do_lease=False)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert result["role"] == "documentation"
|
||||
|
||||
def test_filter_tenant(self, clean_db):
|
||||
"""pick-web --tenant-id filters correctly."""
|
||||
self._add("cma_cgm", "a@cma.com", "A", "tenant-a", "production", "booking")
|
||||
self._add("cma_cgm", "b@cma.com", "B", "tenant-b", "production", "booking")
|
||||
|
||||
from service.account_service import cmd_account_pick_web
|
||||
import io, sys
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_account_pick_web("cma_cgm", environment=None, tenant_id="tenant-b", role=None, do_lease=False)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert result["tenant_id"] == "tenant-b"
|
||||
|
||||
def test_no_accounts_returns_error(self, clean_db):
|
||||
"""pick-web when no account matches returns ERROR:NO_ACCOUNT."""
|
||||
from service.account_service import cmd_account_pick_web
|
||||
import io, sys
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_account_pick_web("maersk", environment="nonexistent", tenant_id=None, role=None, do_lease=False)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
output = captured.getvalue()
|
||||
err = json.loads(output)
|
||||
assert err["success"] is False
|
||||
assert err["error"]["code"] == "ERROR:NO_ACCOUNT"
|
||||
|
||||
def test_platform_alias_chinese(self, clean_db):
|
||||
"""pick-web resolves Chinese platform alias correctly."""
|
||||
self._add("maersk", "test@test.com", "Test", "t", "production", "booking")
|
||||
|
||||
from service.account_service import cmd_account_pick_web
|
||||
import io, sys
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_account_pick_web("马士基", environment=None, tenant_id=None, role=None, do_lease=False)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert result["platform_key"] == "maersk"
|
||||
44
tests/test_browser_open.py
Normal file
44
tests/test_browser_open.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Browser launch helper checks (no real browser started).
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
class TestPlaywrightLaunchHelpers:
|
||||
"""Persistent context launch parts must match RPA contract."""
|
||||
|
||||
def test_persistent_context_includes_maximized(self):
|
||||
from util.playwright_stealth import persistent_context_launch_parts
|
||||
|
||||
args, _ignore = persistent_context_launch_parts()
|
||||
assert "--start-maximized" in args
|
||||
|
||||
def test_open_child_runner_launch_keywords_documented(self):
|
||||
"""launch_persistent_context kwargs contract (static read of source)."""
|
||||
import os
|
||||
|
||||
path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"..",
|
||||
"scripts",
|
||||
"service",
|
||||
"open_child_runner.py",
|
||||
)
|
||||
path = os.path.abspath(path)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
src = f.read()
|
||||
assert "launch_persistent_context" in src
|
||||
assert "headless=False" in src
|
||||
assert "no_viewport=True" in src
|
||||
assert '"channel"' in src or "'channel'" in src
|
||||
|
||||
|
||||
class TestResolveChromiumChannel:
|
||||
"""resolve_chromium_channel returns a channel string when browsers exist."""
|
||||
|
||||
def test_returns_string_or_none(self):
|
||||
from service.browser_service import resolve_chromium_channel
|
||||
|
||||
ch = resolve_chromium_channel()
|
||||
assert ch is None or ch in ("chrome", "msedge")
|
||||
340
tests/test_credentials.py
Normal file
340
tests/test_credentials.py
Normal file
@@ -0,0 +1,340 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Tests for credential operations, secret storage, and reveal.
|
||||
|
||||
Note: windows_credential integration tests are skipped by default
|
||||
(the real Windows Credential Manager requires admin and a real user session).
|
||||
Secret store logic for windows_credential is tested via mocking.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
class TestCredentialEnvStorage:
|
||||
"""credential add-secret env / pick --reveal / SQLite no-secret verification."""
|
||||
|
||||
def test_add_secret_env_saves_ref_not_value(self, clean_db):
|
||||
"""add-secret with storage=env stores only the env var name, not the secret."""
|
||||
from service.account_service import cmd_account_add_secret
|
||||
from db.connection import get_conn
|
||||
|
||||
# Set the env var so the command can read it if needed
|
||||
os.environ["TEST_DEEPSEEK_KEY"] = "sk-test-1234567890abcdef"
|
||||
|
||||
try:
|
||||
cmd_account_add_secret(
|
||||
platform_input="deepseek",
|
||||
credential_type="api_key",
|
||||
label="DeepSeek test key",
|
||||
secret_storage="env",
|
||||
secret_stdin=False,
|
||||
secret_ref="TEST_DEEPSEEK_KEY",
|
||||
account_id=None,
|
||||
environment="production",
|
||||
role="api",
|
||||
extra_json_str=None,
|
||||
)
|
||||
finally:
|
||||
del os.environ["TEST_DEEPSEEK_KEY"]
|
||||
|
||||
conn = get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT secret_ref, secret_mask, secret_storage FROM credentials WHERE platform_key = ?",
|
||||
("deepseek",),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
assert row is not None, "credential should be inserted"
|
||||
secret_ref, secret_mask, secret_storage = row
|
||||
assert secret_storage == "env"
|
||||
assert secret_ref == "TEST_DEEPSEEK_KEY", "secret_ref should be the env var name"
|
||||
# secret_mask should be a masked form of "env:TEST_DEEPSEEK_KEY", NOT the actual secret
|
||||
assert "sk-test" not in secret_mask, "secret_mask must not contain the actual secret value"
|
||||
assert "****" in secret_mask, "mask should contain ****"
|
||||
conn.close()
|
||||
|
||||
def test_credential_pick_reveal_reads_environ(self, clean_db):
|
||||
"""credential pick --reveal retrieves secret value from os.environ."""
|
||||
from service.account_service import cmd_account_add_secret, cmd_credential_pick
|
||||
import io, sys
|
||||
|
||||
os.environ["MY_SECRET_KEY"] = "sk-actual-secret-value-abcdefgh"
|
||||
try:
|
||||
cmd_account_add_secret(
|
||||
platform_input="deepseek",
|
||||
credential_type="api_key",
|
||||
label="Reveal test",
|
||||
secret_storage="env",
|
||||
secret_stdin=False,
|
||||
secret_ref="MY_SECRET_KEY",
|
||||
account_id=None,
|
||||
environment=None,
|
||||
role=None,
|
||||
extra_json_str=None,
|
||||
)
|
||||
|
||||
# credential pick --reveal
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_credential_pick("deepseek", credential_type="api_key", reveal=True)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert result["success"] is True
|
||||
assert result["secret"] == "sk-actual-secret-value-abcdefgh"
|
||||
assert result["secret_mask"] != result["secret"], "mask should differ from secret"
|
||||
finally:
|
||||
del os.environ["MY_SECRET_KEY"]
|
||||
|
||||
def test_credential_pick_no_reveal_masks(self, clean_db):
|
||||
"""credential pick (no --reveal) does NOT include the secret field."""
|
||||
from service.account_service import cmd_account_add_secret, cmd_credential_pick
|
||||
import io, sys
|
||||
|
||||
os.environ["MASK_TEST_KEY"] = "super-secret-value-xyz"
|
||||
try:
|
||||
cmd_account_add_secret(
|
||||
platform_input="deepseek",
|
||||
credential_type="api_key",
|
||||
label="Mask test",
|
||||
secret_storage="env",
|
||||
secret_stdin=False,
|
||||
secret_ref="MASK_TEST_KEY",
|
||||
account_id=None,
|
||||
environment=None,
|
||||
role=None,
|
||||
extra_json_str=None,
|
||||
)
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_credential_pick("deepseek", credential_type="api_key", reveal=False)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert result["success"] is True
|
||||
assert "secret" not in result, "no secret field when reveal=False"
|
||||
assert result["secret_mask"] != "super-secret-value-xyz"
|
||||
finally:
|
||||
del os.environ["MASK_TEST_KEY"]
|
||||
|
||||
def test_env_missing_returns_error(self, clean_db):
|
||||
"""credential pick --reveal when env var is missing returns SECRET_ENV_MISSING."""
|
||||
from service.account_service import cmd_account_add_secret, cmd_credential_pick
|
||||
import io, sys
|
||||
|
||||
# Add credential with a ref to a non-existent env var
|
||||
cmd_account_add_secret(
|
||||
platform_input="deepseek",
|
||||
credential_type="api_key",
|
||||
label="Missing env test",
|
||||
secret_storage="env",
|
||||
secret_stdin=False,
|
||||
secret_ref="THIS_ENV_VAR_DOES_NOT_EXIST_AT_ALL",
|
||||
account_id=None,
|
||||
environment=None,
|
||||
role=None,
|
||||
extra_json_str=None,
|
||||
)
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_credential_pick("deepseek", credential_type="api_key", reveal=True)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert result["success"] is False
|
||||
assert result["error"]["code"] == "ERROR:SECRET_ENV_MISSING"
|
||||
|
||||
|
||||
class TestCredentialNoneStorage:
|
||||
"""credential add-secret storage=none (browser_profile / note)."""
|
||||
|
||||
def test_add_secret_none(self, clean_db):
|
||||
"""storage=none works for browser_profile credential type."""
|
||||
from service.account_service import cmd_account_add_secret
|
||||
|
||||
cmd_account_add_secret(
|
||||
platform_input="maersk",
|
||||
credential_type="browser_profile",
|
||||
label="Maersk browser session",
|
||||
secret_storage="none",
|
||||
secret_stdin=False,
|
||||
secret_ref=None,
|
||||
account_id=None,
|
||||
environment="simulator",
|
||||
role="booking",
|
||||
extra_json_str=None,
|
||||
)
|
||||
|
||||
from db.connection import get_conn
|
||||
conn = get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT secret_storage, secret_ref, secret_mask FROM credentials WHERE platform_key = ?",
|
||||
("maersk",),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
assert row is not None
|
||||
assert row[0] == "none"
|
||||
assert row[1] is None or row[1] == ""
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestCredentialWindowsCredentialMocked:
|
||||
"""Mocked tests for windows_credential secret store logic."""
|
||||
|
||||
def test_mask_secret_never_returns_full_value(self):
|
||||
"""mask_secret must never return the actual secret value."""
|
||||
from service.secret_store import mask_secret
|
||||
|
||||
secrets = [
|
||||
"sk-test-1234567890abcdefghijklmnop",
|
||||
"short",
|
||||
"abc",
|
||||
"a" * 100,
|
||||
"",
|
||||
None,
|
||||
]
|
||||
for s in secrets:
|
||||
if s is None or s == "":
|
||||
masked = mask_secret(s)
|
||||
assert masked == "****"
|
||||
continue
|
||||
result = mask_secret(s)
|
||||
# The mask should never equal the original
|
||||
assert result != s, f"mask_secret({s!r}) must not equal original"
|
||||
# The mask should always contain "****"
|
||||
assert "****" in result, f"mask should contain '****': {result}"
|
||||
# For long secrets, original should not appear in mask
|
||||
assert s not in result, f"original secret must not appear in mask: {s}"
|
||||
|
||||
def test_read_secret_env_success(self):
|
||||
"""read_secret with storage=env returns the os.environ value."""
|
||||
os.environ["TEST_ENV_FOR_READ"] = "env-secret-value-xyz"
|
||||
try:
|
||||
from service.secret_store import read_secret
|
||||
val = read_secret("env", "TEST_ENV_FOR_READ")
|
||||
assert val == "env-secret-value-xyz"
|
||||
finally:
|
||||
del os.environ["TEST_ENV_FOR_READ"]
|
||||
|
||||
def test_read_secret_env_missing_raises(self):
|
||||
"""read_secret with storage=env raises ValueError when env var is missing."""
|
||||
# Ensure the env var is not set
|
||||
os.environ.pop("NONEXISTENT_VAR_FOR_TEST_123", None)
|
||||
from service.secret_store import read_secret
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
read_secret("env", "NONEXISTENT_VAR_FOR_TEST_123")
|
||||
assert "SECRET_ENV_MISSING" in str(exc_info.value)
|
||||
|
||||
def test_read_secret_none(self):
|
||||
"""read_secret with storage=none returns empty string."""
|
||||
from service.secret_store import read_secret
|
||||
val = read_secret("none", "ignored-ref")
|
||||
assert val == ""
|
||||
|
||||
def test_write_secret_none_is_noop(self):
|
||||
"""write_secret with storage=none does not raise."""
|
||||
from service.secret_store import write_secret
|
||||
# Should not raise
|
||||
write_secret("none", "ignored-ref", "any-secret-value")
|
||||
|
||||
def test_windows_credential_write_fails_on_non_win32(self, monkeypatch):
|
||||
"""On non-Windows, windows_credential write raises OSError."""
|
||||
import sys
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
from service.secret_store import write_secret
|
||||
with pytest.raises(OSError) as exc_info:
|
||||
write_secret("windows_credential", "some-target", "some-secret")
|
||||
assert "WINDOWS_CREDENTIAL_UNAVAILABLE" in str(exc_info.value) or \
|
||||
"not available" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
class TestCredentialPickFiltering:
|
||||
"""credential pick filtering by credential_type / environment."""
|
||||
|
||||
def test_pick_by_credential_type(self, clean_db):
|
||||
from service.account_service import cmd_account_add_secret, cmd_credential_pick
|
||||
import io, sys
|
||||
|
||||
os.environ["CREDS_TYPE_KEY_A"] = "val-a"
|
||||
os.environ["CREDS_TYPE_KEY_B"] = "val-b"
|
||||
try:
|
||||
cmd_account_add_secret(
|
||||
"deepseek", "api_key", "A", "env", False, "CREDS_TYPE_KEY_A",
|
||||
None, None, None, None,
|
||||
)
|
||||
cmd_account_add_secret(
|
||||
"deepseek", "model_param", "B", "env", False, "CREDS_TYPE_KEY_B",
|
||||
None, None, None, None,
|
||||
)
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_credential_pick("deepseek", credential_type="model_param", reveal=False)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert result["success"] is True
|
||||
assert result["credential_type"] == "model_param"
|
||||
finally:
|
||||
del os.environ["CREDS_TYPE_KEY_A"]
|
||||
del os.environ["CREDS_TYPE_KEY_B"]
|
||||
|
||||
def test_pick_no_credential_returns_error(self, clean_db):
|
||||
"""credential pick when none matches returns CREDENTIAL_NOT_FOUND."""
|
||||
from service.account_service import cmd_credential_pick
|
||||
import io, sys
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_credential_pick("deepseek", credential_type="nonexistent_type", reveal=False)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert result["success"] is False
|
||||
assert result["error"]["code"] == "ERROR:CREDENTIAL_NOT_FOUND"
|
||||
|
||||
|
||||
class TestSQLiteNoPlaintextSecret:
|
||||
"""Verify that SQLite never stores actual secret values."""
|
||||
|
||||
def test_credentials_table_never_has_plaintext(self, clean_db):
|
||||
"""All credential rows should have secret_mask != original secret value."""
|
||||
from service.account_service import cmd_account_add_secret
|
||||
from db.connection import get_conn
|
||||
|
||||
test_secret = "SQLITE_NO_PLAINTEXT_TEST_SECRET_987654321"
|
||||
os.environ["SQLITE_NO_PLAINTEXT_TEST"] = test_secret
|
||||
try:
|
||||
cmd_account_add_secret(
|
||||
"deepseek", "api_key", "SQLite test", "env", False,
|
||||
"SQLITE_NO_PLAINTEXT_TEST", None, None, None, None,
|
||||
)
|
||||
finally:
|
||||
del os.environ["SQLITE_NO_PLAINTEXT_TEST"]
|
||||
|
||||
conn = get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT secret_ref, secret_mask FROM credentials")
|
||||
rows = cur.fetchall()
|
||||
conn.close()
|
||||
|
||||
for secret_ref, secret_mask in rows:
|
||||
# secret_ref for env storage is the env var name, not the secret
|
||||
if secret_ref == "SQLITE_NO_PLAINTEXT_TEST":
|
||||
# If it matches our test env var name, check the mask
|
||||
assert test_secret not in (secret_mask or ""), \
|
||||
f"Plaintext secret must not appear in secret_mask or secret_ref columns. mask={secret_mask}"
|
||||
242
tests/test_lease.py
Normal file
242
tests/test_lease.py
Normal file
@@ -0,0 +1,242 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Tests for lease acquire / conflict / release / cleanup.
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
import pytest
|
||||
|
||||
|
||||
class TestLeaseAcquire:
|
||||
"""Lease acquisition on pick-web."""
|
||||
|
||||
def _add_account(self, platform, login, label, tenant, env, role):
|
||||
from service.account_service import cmd_account_add_web
|
||||
cmd_account_add_web(
|
||||
platform, login, "email", label, tenant, env, role,
|
||||
None, None, None, None,
|
||||
)
|
||||
|
||||
def test_pick_web_lease_returns_token(self, clean_db):
|
||||
"""pick-web --lease returns lease_token in result."""
|
||||
self._add_account("maersk", "test@maersk.com", "Test", "t1", "production", "booking")
|
||||
|
||||
from service.account_service import cmd_account_pick_web
|
||||
import io, sys
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_account_pick_web(
|
||||
"maersk",
|
||||
environment=None,
|
||||
tenant_id=None,
|
||||
role=None,
|
||||
do_lease=True,
|
||||
ttl_sec=900,
|
||||
holder="test-holder",
|
||||
purpose="rpa-run",
|
||||
)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert "lease_token" in result
|
||||
assert len(result["lease_token"]) == 32
|
||||
assert "lease_expires_at" in result
|
||||
assert result["lease_token"] not in ("", "none")
|
||||
|
||||
def test_active_lease_blocks_pick(self, clean_db):
|
||||
"""When the only matching account is leased, pick-web finds no eligible row."""
|
||||
from db.accounts_repo import acquire_lease, find_account_ids
|
||||
|
||||
self._add_account("cma_cgm", "test@cma.com", "CMA", "t1", "production", "booking")
|
||||
ids = find_account_ids("cma_cgm")
|
||||
assert len(ids) == 1
|
||||
acc_id = ids[0]
|
||||
|
||||
acquire_lease(
|
||||
account_id=acc_id,
|
||||
lease_token="a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
|
||||
holder="first-holder",
|
||||
purpose="first-task",
|
||||
ttl_sec=900,
|
||||
)
|
||||
|
||||
assert find_account_ids("cma_cgm") == []
|
||||
|
||||
from service.account_service import cmd_account_pick_web
|
||||
import io, sys
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_account_pick_web(
|
||||
"cma_cgm",
|
||||
environment=None,
|
||||
tenant_id=None,
|
||||
role=None,
|
||||
do_lease=True,
|
||||
ttl_sec=900,
|
||||
holder="second-holder",
|
||||
purpose="second-task",
|
||||
)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert result["success"] is False
|
||||
assert result["error"]["code"] == "ERROR:NO_ACCOUNT"
|
||||
|
||||
def test_release_then_pick(self, clean_db):
|
||||
"""After release, the same account can be picked again."""
|
||||
from db.accounts_repo import acquire_lease, release_lease, find_account_ids
|
||||
|
||||
self._add_account("evergreen", "ship@evergreen.com", "EVG", "t1", "production", "booking")
|
||||
ids = find_account_ids("evergreen")
|
||||
acc_id = ids[0]
|
||||
|
||||
token = "z9y8x7w6v5u4t3s2r1q0p9o8n7m6l5k4"
|
||||
acquire_lease(acc_id, token, "holder", "purpose", 900)
|
||||
ok = release_lease(token)
|
||||
assert ok, "release should succeed"
|
||||
|
||||
from service.account_service import cmd_account_pick_web
|
||||
import io, sys
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_account_pick_web(
|
||||
"evergreen",
|
||||
environment=None,
|
||||
tenant_id=None,
|
||||
role=None,
|
||||
do_lease=True,
|
||||
ttl_sec=900,
|
||||
holder="new-holder",
|
||||
purpose="new-task",
|
||||
)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue().strip().splitlines()[-1])
|
||||
assert result["id"] == acc_id
|
||||
assert "lease_token" in result
|
||||
|
||||
|
||||
class TestLeaseRelease:
|
||||
"""lease release command."""
|
||||
|
||||
def test_release_unknown_token(self, clean_db):
|
||||
"""release of non-existent token returns LEASE_NOT_FOUND."""
|
||||
from service.account_service import cmd_lease_release
|
||||
import io, sys
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_lease_release("nonexistent_token_123456789012")
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert result["success"] is False
|
||||
assert result["error"]["code"] == "ERROR:LEASE_NOT_FOUND"
|
||||
|
||||
|
||||
class TestLeaseCleanup:
|
||||
"""lease cleanup / expired lease handling."""
|
||||
|
||||
def test_expired_lease_allows_pick(self, clean_db):
|
||||
"""After cleanup marks expired, account can be picked again."""
|
||||
from db.accounts_repo import acquire_lease, cleanup_expired_leases, find_account_ids
|
||||
|
||||
self._add_account_("cargo_wise", "cw@cargowise.com", "CW", "t1", "production", "booking")
|
||||
ids = find_account_ids("cargo_wise")
|
||||
acc_id = ids[0]
|
||||
|
||||
# Acquire lease with very short TTL (1 second)
|
||||
acquire_lease(acc_id, "shortlived1234567890123456789012", "holder", "short", 1)
|
||||
# Wait for it to expire
|
||||
time.sleep(1.5)
|
||||
count = cleanup_expired_leases()
|
||||
assert count >= 1, "at least one lease should be cleaned"
|
||||
|
||||
# Now pick-web should succeed
|
||||
from service.account_service import cmd_account_pick_web
|
||||
import io, sys
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_account_pick_web(
|
||||
"cargo_wise",
|
||||
environment=None,
|
||||
tenant_id=None,
|
||||
role=None,
|
||||
do_lease=True,
|
||||
ttl_sec=900,
|
||||
holder="after-cleanup",
|
||||
purpose="fresh-task",
|
||||
)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue().strip().splitlines()[-1])
|
||||
assert result["id"] == acc_id
|
||||
assert "lease_token" in result
|
||||
|
||||
def _add_account_(self, platform, login, label, tenant, env, role):
|
||||
from service.account_service import cmd_account_add_web
|
||||
cmd_account_add_web(
|
||||
platform, login, "email", label, tenant, env, role,
|
||||
None, None, None, None,
|
||||
)
|
||||
|
||||
|
||||
class TestLeaseList:
|
||||
"""lease list command."""
|
||||
|
||||
def test_list_no_leases(self, clean_db):
|
||||
"""lease list --active returns empty when no leases."""
|
||||
from service.account_service import cmd_lease_list
|
||||
import io, sys
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_lease_list(active_only=True)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert result["success"] is True
|
||||
assert result["data"]["leases"] == []
|
||||
|
||||
def test_list_with_active_lease(self, clean_db):
|
||||
"""lease list --active shows active lease after acquisition."""
|
||||
from db.accounts_repo import acquire_lease, find_account_ids
|
||||
from service.account_service import cmd_account_add_web, cmd_lease_list
|
||||
import io, sys
|
||||
|
||||
cmd_account_add_web(
|
||||
"sinotrans", "sinotest@sinotrans.com", "email", "Sino", "t1", "production", "booking",
|
||||
None, None, None, None,
|
||||
)
|
||||
ids = find_account_ids("sinotrans")
|
||||
acquire_lease(ids[0], "listtest1234567890123456789012345", "holder", "purpose", 900)
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_lease_list(active_only=True)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert result["success"] is True
|
||||
leases = result["data"]["leases"]
|
||||
assert len(leases) == 1
|
||||
assert leases[0]["account_id"] == ids[0]
|
||||
assert leases[0]["status"] == "active"
|
||||
83
tests/test_platforms.py
Normal file
83
tests/test_platforms.py
Normal file
@@ -0,0 +1,83 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Platform resolution tests.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
class TestPlatformResolution:
|
||||
"""Platform key resolution: key / display_name / aliases -> canonical key."""
|
||||
|
||||
def test_maersk_key(self):
|
||||
from util.platforms import resolve_platform_key
|
||||
assert resolve_platform_key("maersk") == "maersk"
|
||||
|
||||
def test_maersk_alias_chinese(self):
|
||||
from util.platforms import resolve_platform_key
|
||||
assert resolve_platform_key("马士基") == "maersk"
|
||||
|
||||
def test_maersk_alias_upper(self):
|
||||
from util.platforms import resolve_platform_key
|
||||
assert resolve_platform_key("MAERSK") == "maersk"
|
||||
|
||||
def test_cma_cgm_key(self):
|
||||
from util.platforms import resolve_platform_key
|
||||
assert resolve_platform_key("cma_cgm") == "cma_cgm"
|
||||
|
||||
def test_cma_cgm_alias_chinese(self):
|
||||
from util.platforms import resolve_platform_key
|
||||
assert resolve_platform_key("达飞") == "cma_cgm"
|
||||
|
||||
def test_cma_cgm_alias_display(self):
|
||||
from util.platforms import resolve_platform_key
|
||||
assert resolve_platform_key("CMA CGM") == "cma_cgm"
|
||||
|
||||
def test_deepseek_key(self):
|
||||
from util.platforms import resolve_platform_key
|
||||
assert resolve_platform_key("deepseek") == "deepseek"
|
||||
|
||||
def test_deepseek_alias_capitalize(self):
|
||||
from util.platforms import resolve_platform_key
|
||||
assert resolve_platform_key("DeepSeek") == "deepseek"
|
||||
|
||||
def test_unknown_returns_none(self):
|
||||
from util.platforms import resolve_platform_key
|
||||
assert resolve_platform_key("not_a_platform_xyz") is None
|
||||
|
||||
def test_empty_returns_none(self):
|
||||
from util.platforms import resolve_platform_key
|
||||
assert resolve_platform_key("") is None
|
||||
assert resolve_platform_key(None) is None
|
||||
|
||||
def test_list_platforms_all(self):
|
||||
from util.platforms import list_platforms
|
||||
all_plat = list_platforms()
|
||||
assert any(p["platform_key"] == "maersk" for p in all_plat)
|
||||
assert any(p["platform_key"] == "deepseek" for p in all_plat)
|
||||
assert any(p["platform_key"] == "sohu" for p in all_plat)
|
||||
|
||||
def test_list_platforms_logistics_only(self):
|
||||
from util.platforms import list_platforms
|
||||
log_plat = list_platforms("logistics")
|
||||
assert any(p["platform_key"] == "maersk" for p in log_plat)
|
||||
assert any(p["platform_key"] == "cma_cgm" for p in log_plat)
|
||||
assert not any(p["platform_key"] == "deepseek" for p in log_plat)
|
||||
|
||||
def test_list_platforms_llm_only(self):
|
||||
from util.platforms import list_platforms
|
||||
llm_plat = list_platforms("llm")
|
||||
assert any(p["platform_key"] == "deepseek" for p in llm_plat)
|
||||
assert any(p["platform_key"] == "doubao" for p in llm_plat)
|
||||
assert not any(p["platform_key"] == "maersk" for p in llm_plat)
|
||||
|
||||
def test_evergreen_key(self):
|
||||
from util.platforms import resolve_platform_key
|
||||
assert resolve_platform_key("evergreen") == "evergreen"
|
||||
|
||||
def test_evergreen_alias_chinese(self):
|
||||
from util.platforms import resolve_platform_key
|
||||
assert resolve_platform_key("长荣") == "evergreen"
|
||||
|
||||
def test_cargo_wise_key(self):
|
||||
from util.platforms import resolve_platform_key
|
||||
assert resolve_platform_key("cargo_wise") == "cargo_wise"
|
||||
Reference in New Issue
Block a user