327 lines
13 KiB
Python
327 lines
13 KiB
Python
#!/usr/bin/env python3
|
||||
|
|
"""WorkBuddy / CodeBuddy (Tencent) daily check-in client.
|
|||
|
|
|
|||
|
|
Endpoints (reverse-engineered from the official desktop client, see
|
|||
|
|
community projects cpa-plugin/workbuddy and wb-trae-checkin):
|
|||
|
|
|
|||
|
|
region CN base https://www.codebuddy.cn (daily check-in supported)
|
|||
|
|
region Global base https://www.workbuddy.ai (no daily check-in)
|
|||
|
|
|
|||
|
|
status POST {base}/v2/billing/meter/checkin-activity-status
|
|||
|
|
claim POST {base}/v2/billing/meter/daily-checkin
|
|||
|
|
credits POST {base}/v2/billing/meter/get-user-resource
|
|||
|
|
refresh POST https://copilot.tencent.com/v2/plugin/auth/token/refresh
|
|||
|
|
(Global: https://www.workbuddy.ai/v2/plugin/auth/token/refresh)
|
|||
|
|
oauth GET https://copilot.tencent.com/v2/plugin/auth/state?platform=CLI
|
|||
|
|
GET https://copilot.tencent.com/v2/plugin/auth/token?state=...
|
|||
|
|
GET https://copilot.tencent.com/v2/plugin/login/account?state=...
|
|||
|
|
|
|||
|
|
Result codes used across the app: ok / already / fail / no_session / inactive.
|
|||
|
|
"""
|
|||
|
|
import json
|
|||
|
|
import time
|
|||
|
|
|
|||
|
|
from common import (http_post_json, http_get, log, now_ts, fmt_now)
|
|||
|
|
|
|||
|
|
CN_BASE = "https://www.codebuddy.cn"
|
|||
|
|
GLOBAL_BASE = "https://www.workbuddy.ai"
|
|||
|
|
REFRESH_URL_CN = "https://copilot.tencent.com/v2/plugin/auth/token/refresh"
|
|||
|
|
REFRESH_URL_GLOBAL = "https://www.workbuddy.ai/v2/plugin/auth/token/refresh"
|
|||
|
|
AUTH_BASE = "https://copilot.tencent.com"
|
|||
|
|
|
|||
|
|
EP_STATUS = "/v2/billing/meter/checkin-activity-status"
|
|||
|
|
EP_DAILY_CHECKIN = "/v2/billing/meter/daily-checkin"
|
|||
|
|
EP_USER_RESOURCE = "/v2/billing/meter/get-user-resource"
|
|||
|
|
|
|||
|
|
REFRESH_MARGIN = 3 * 24 * 3600 # refresh 3 days before expiry
|
|||
|
|
SESSION_DEAD = ("12153", "Offline user session not found")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def is_global(auth):
|
|||
|
|
return (auth.get("domain") or "").lower() in ("workbuddy.ai", "global")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def billing_base(auth):
|
|||
|
|
return GLOBAL_BASE if is_global(auth) else CN_BASE
|
|||
|
|
|
|||
|
|
|
|||
|
|
def refresh_url(auth):
|
|||
|
|
return REFRESH_URL_GLOBAL if is_global(auth) else REFRESH_URL_CN
|
|||
|
|
|
|||
|
|
|
|||
|
|
def billing_headers(auth, account=None):
|
|||
|
|
account = account or {}
|
|||
|
|
headers = {"Authorization": "Bearer %s" % auth.get("accessToken", "")}
|
|||
|
|
if account.get("uid"):
|
|||
|
|
headers["X-User-Id"] = str(account["uid"])
|
|||
|
|
if account.get("enterpriseId"):
|
|||
|
|
headers["X-Enterprise-Id"] = str(account["enterpriseId"])
|
|||
|
|
headers["X-Tenant-Id"] = str(account["enterpriseId"])
|
|||
|
|
if auth.get("domain"):
|
|||
|
|
headers["X-Domain"] = auth["domain"]
|
|||
|
|
base = billing_base(auth)
|
|||
|
|
headers["Referer"] = base + "/"
|
|||
|
|
headers["Origin"] = base
|
|||
|
|
return headers
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Token lifecycle
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def _expires_at(auth):
|
|||
|
|
exp = auth.get("expiresAt") or 0
|
|||
|
|
try:
|
|||
|
|
exp = int(exp)
|
|||
|
|
except (TypeError, ValueError):
|
|||
|
|
return 0
|
|||
|
|
if exp > 10 ** 12: # milliseconds
|
|||
|
|
exp //= 1000
|
|||
|
|
return exp
|
|||
|
|
|
|||
|
|
|
|||
|
|
def token_expiring(auth, margin=REFRESH_MARGIN):
|
|||
|
|
exp = _expires_at(auth)
|
|||
|
|
if not exp:
|
|||
|
|
return False
|
|||
|
|
return now_ts() + margin > exp
|
|||
|
|
|
|||
|
|
|
|||
|
|
def refresh_token(account):
|
|||
|
|
"""Refresh access token in place. Returns (ok, message)."""
|
|||
|
|
auth = account.setdefault("auth", {})
|
|||
|
|
rt = auth.get("refreshToken", "")
|
|||
|
|
if not rt:
|
|||
|
|
return False, "未配置 refreshToken,无法自动续期"
|
|||
|
|
headers = {
|
|||
|
|
"X-Refresh-Token": rt,
|
|||
|
|
"X-Auth-Refresh-Source": "workbuddy",
|
|||
|
|
}
|
|||
|
|
if (account.get("account") or {}).get("enterpriseId"):
|
|||
|
|
headers["X-Enterprise-Id"] = str(account["account"]["enterpriseId"])
|
|||
|
|
data, status, err = http_post_json(refresh_url(auth), body={}, headers=headers)
|
|||
|
|
if err or not data:
|
|||
|
|
if status == 401 and any(m in (err or "") for m in SESSION_DEAD):
|
|||
|
|
account["disabled"] = True
|
|||
|
|
account["note"] = "会话已失效,需要重新授权"
|
|||
|
|
return False, "登录态已失效,请重新授权"
|
|||
|
|
return False, "续期失败(HTTP %s):%s" % (status, err)
|
|||
|
|
if not isinstance(data, dict) or not data.get("accessToken"):
|
|||
|
|
return False, "续期响应缺少 accessToken"
|
|||
|
|
auth["accessToken"] = data["accessToken"]
|
|||
|
|
if data.get("refreshToken"):
|
|||
|
|
auth["refreshToken"] = data["refreshToken"]
|
|||
|
|
if data.get("domain"):
|
|||
|
|
auth["domain"] = data["domain"]
|
|||
|
|
if data.get("expiresAt") or data.get("expires_at"):
|
|||
|
|
auth["expiresAt"] = data.get("expiresAt") or data.get("expires_at")
|
|||
|
|
account["note"] = ""
|
|||
|
|
return True, "已续期"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Billing API
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def query_status(account):
|
|||
|
|
"""Return (checked, detail, error). checked is True/False/None."""
|
|||
|
|
auth = account.get("auth", {})
|
|||
|
|
headers = billing_headers(auth, account.get("account"))
|
|||
|
|
data, status, err = http_post_json(billing_base(auth) + EP_STATUS, body={}, headers=headers)
|
|||
|
|
if err or data is None:
|
|||
|
|
return None, None, err or "empty response"
|
|||
|
|
if status in (401, 403) or any(m in (err or "") for m in SESSION_DEAD):
|
|||
|
|
return None, None, "no_session"
|
|||
|
|
d = data.get("data") if isinstance(data, dict) else None
|
|||
|
|
d = d if isinstance(d, dict) else {}
|
|||
|
|
for key in ("today_checked_in", "checked_in", "checkedIn", "hasCheckin", "isCheckedIn", "checked"):
|
|||
|
|
if isinstance(d.get(key), bool):
|
|||
|
|
return d[key], d, None
|
|||
|
|
if isinstance(data.get(key), bool):
|
|||
|
|
return data[key], d, None
|
|||
|
|
return None, d, None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def perform_checkin(account):
|
|||
|
|
"""Claim today's credits. Returns (status, message, points).
|
|||
|
|
|
|||
|
|
status: ok / already / fail / no_session
|
|||
|
|
"""
|
|||
|
|
auth = account.get("auth", {})
|
|||
|
|
headers = billing_headers(auth, account.get("account"))
|
|||
|
|
data, status, err = http_post_json(billing_base(auth) + EP_DAILY_CHECKIN, body={}, headers=headers)
|
|||
|
|
if status in (401, 403):
|
|||
|
|
return "no_session", "登录态已失效,请重新授权", 0
|
|||
|
|
if data is None:
|
|||
|
|
return "fail", "请求失败(HTTP %s:%s)" % (status, err), 0
|
|||
|
|
code = data.get("code") if isinstance(data, dict) else 0
|
|||
|
|
if code in (10001,):
|
|||
|
|
return "already", "今天已签到", 0
|
|||
|
|
if code in (0, 200) or code is None:
|
|||
|
|
d = data.get("data") if isinstance(data, dict) else {}
|
|||
|
|
d = d if isinstance(d, dict) else {}
|
|||
|
|
points = d.get("today_credit", d.get("daily_credit", d.get("points", 0)))
|
|||
|
|
return "ok", "签到成功", points or 0
|
|||
|
|
if any(m in (err or "") for m in SESSION_DEAD):
|
|||
|
|
return "no_session", "登录态已失效,请重新授权", 0
|
|||
|
|
return "fail", "领取返回 code=%s %s" % (code, (data.get("msg") or "")[:80]), 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
def fetch_credits(account):
|
|||
|
|
"""Aggregate credit packages. Returns (summary, error)."""
|
|||
|
|
auth = account.get("auth", {})
|
|||
|
|
headers = billing_headers(auth, account.get("account"))
|
|||
|
|
body = {
|
|||
|
|
"PageNumber": 1,
|
|||
|
|
"PageSize": 100,
|
|||
|
|
"ProductCode": "p_tcaca",
|
|||
|
|
"Status": [0, 3],
|
|||
|
|
"PackageEndTimeRangeBegin": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
|||
|
|
"PackageEndTimeRangeEnd": "2126-12-31 23:59:59",
|
|||
|
|
}
|
|||
|
|
data, status, err = http_post_json(billing_base(auth) + EP_USER_RESOURCE, body=body, headers=headers)
|
|||
|
|
if err or data is None:
|
|||
|
|
if status in (401, 403):
|
|||
|
|
return None, "no_session"
|
|||
|
|
return None, err or "查询失败"
|
|||
|
|
resp = data
|
|||
|
|
if isinstance(data, dict) and "Response" in data:
|
|||
|
|
resp = data.get("Response") or {}
|
|||
|
|
resp_data = resp.get("Data", {}) if isinstance(resp, dict) else {}
|
|||
|
|
accounts = resp_data.get("Accounts", []) if isinstance(resp_data, dict) else []
|
|||
|
|
if not isinstance(accounts, list):
|
|||
|
|
accounts = []
|
|||
|
|
total_remain = total_used = total_size = 0
|
|||
|
|
packages = []
|
|||
|
|
for pkg in accounts:
|
|||
|
|
if not isinstance(pkg, dict):
|
|||
|
|
continue
|
|||
|
|
remain, used, size = _package_remain_used(pkg)
|
|||
|
|
total_remain += remain
|
|||
|
|
total_used += used
|
|||
|
|
total_size += size
|
|||
|
|
packages.append({
|
|||
|
|
"name": pkg.get("PackageName", ""),
|
|||
|
|
"remain": remain, "used": used, "size": size,
|
|||
|
|
})
|
|||
|
|
dosage = resp_data.get("TotalDosage", 0)
|
|||
|
|
if isinstance(dosage, (int, float)) and dosage > total_size:
|
|||
|
|
total_size = dosage
|
|||
|
|
derived = total_size - total_remain
|
|||
|
|
if derived > total_used:
|
|||
|
|
total_used = derived
|
|||
|
|
return {
|
|||
|
|
"remain": total_remain,
|
|||
|
|
"used": total_used,
|
|||
|
|
"size": total_size,
|
|||
|
|
"packages": packages,
|
|||
|
|
}, None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _package_remain_used(pkg):
|
|||
|
|
def num(v):
|
|||
|
|
return v if isinstance(v, (int, float)) else 0
|
|||
|
|
cycle_size = num(pkg.get("CycleCapacitySize"))
|
|||
|
|
if cycle_size > 0:
|
|||
|
|
remain = max(num(pkg.get("CycleCapacityRemain")), 0)
|
|||
|
|
remain = min(remain, cycle_size)
|
|||
|
|
used = cycle_size - remain
|
|||
|
|
explicit = num(pkg.get("CycleCapacityUsed"))
|
|||
|
|
if explicit > used:
|
|||
|
|
used = explicit
|
|||
|
|
if cycle_size >= used:
|
|||
|
|
remain = cycle_size - used
|
|||
|
|
return remain, used, cycle_size
|
|||
|
|
cycle_remain = num(pkg.get("CycleCapacityRemain"))
|
|||
|
|
cycle_used = num(pkg.get("CycleCapacityUsed"))
|
|||
|
|
if cycle_remain > 0 or cycle_used > 0:
|
|||
|
|
remain = max(cycle_remain, 0)
|
|||
|
|
used = max(cycle_used, 0)
|
|||
|
|
size = remain + used
|
|||
|
|
cap = num(pkg.get("CapacitySize"))
|
|||
|
|
if cap > size:
|
|||
|
|
size = cap
|
|||
|
|
if size >= remain:
|
|||
|
|
used = size - remain
|
|||
|
|
return remain, used, size
|
|||
|
|
remain = max(num(pkg.get("CapacityRemain")), 0)
|
|||
|
|
used = max(num(pkg.get("CapacityUsed")), 0)
|
|||
|
|
size = num(pkg.get("CapacitySize"))
|
|||
|
|
if size <= 0:
|
|||
|
|
size = remain + used
|
|||
|
|
if used == 0 and size > remain:
|
|||
|
|
used = size - remain
|
|||
|
|
return remain, used, size
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# OAuth web login (CLI-style device flow, driven from the NAS web UI)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def begin_oauth():
|
|||
|
|
"""Start a login session. Returns (state, login_url, error)."""
|
|||
|
|
data, status, err = http_post_json(AUTH_BASE + "/v2/plugin/auth/state?platform=CLI",
|
|||
|
|
body={}, timeout=15)
|
|||
|
|
if err or not isinstance(data, dict):
|
|||
|
|
return None, None, "获取授权状态失败(HTTP %s:%s)" % (status, err)
|
|||
|
|
d = data.get("data") if isinstance(data.get("data"), dict) else data
|
|||
|
|
state = d.get("state") or d.get("State")
|
|||
|
|
if not state:
|
|||
|
|
return None, None, "未能获取授权 state"
|
|||
|
|
login_url = "%s/login?state=%s&platform=CLI" % (AUTH_BASE, state)
|
|||
|
|
return state, login_url, None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def poll_oauth(state):
|
|||
|
|
"""Poll the token endpoint. Returns (status, payload, error).
|
|||
|
|
|
|||
|
|
status: waiting / done / failed
|
|||
|
|
"""
|
|||
|
|
data, status_code, err = http_get(
|
|||
|
|
AUTH_BASE + "/v2/plugin/auth/token?state=%s" % state, timeout=15)
|
|||
|
|
if status_code == 200 and isinstance(data, dict):
|
|||
|
|
code = data.get("code", 0)
|
|||
|
|
d = data.get("data") if isinstance(data.get("data"), dict) else {}
|
|||
|
|
if code in (0, 200) and (d.get("accessToken") or d.get("access_token")):
|
|||
|
|
expires_at = d.get("expiresAt") or d.get("expires_at") or 0
|
|||
|
|
if not expires_at and (d.get("expires_in") or d.get("expiresIn")):
|
|||
|
|
from common import now_ts
|
|||
|
|
expires_at = now_ts() + int(d.get("expires_in") or d.get("expiresIn"))
|
|||
|
|
payload = {
|
|||
|
|
"accessToken": d.get("accessToken") or d.get("access_token"),
|
|||
|
|
"refreshToken": d.get("refreshToken") or d.get("refresh_token"),
|
|||
|
|
"expiresAt": expires_at,
|
|||
|
|
"domain": d.get("domain") or "codebuddy.cn",
|
|||
|
|
}
|
|||
|
|
account = fetch_account_info(state, payload["accessToken"])
|
|||
|
|
if account:
|
|||
|
|
payload["account"] = account
|
|||
|
|
return "done", payload, None
|
|||
|
|
# not logged in yet (or state expired) — keep waiting within the UI timeout
|
|||
|
|
return "waiting", None, None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def fetch_account_info(state, access_token):
|
|||
|
|
"""Best-effort account info for the OAuth session."""
|
|||
|
|
headers = {"Authorization": "Bearer %s" % access_token} if access_token else None
|
|||
|
|
data, status, err = http_get(
|
|||
|
|
AUTH_BASE + "/v2/plugin/login/account?state=%s" % state, headers=headers, timeout=15)
|
|||
|
|
if not isinstance(data, dict):
|
|||
|
|
return None
|
|||
|
|
d = data.get("data") if isinstance(data.get("data"), dict) else data
|
|||
|
|
uid = d.get("uid") or d.get("userId") or d.get("Uid")
|
|||
|
|
nickname = d.get("nickname") or d.get("name") or d.get("Nickname")
|
|||
|
|
enterprise = d.get("enterpriseId") or d.get("EnterpriseId") or d.get("enterprise_id")
|
|||
|
|
if not (uid or nickname):
|
|||
|
|
return None
|
|||
|
|
return {
|
|||
|
|
"uid": str(uid) if uid else "",
|
|||
|
|
"nickname": str(nickname) if nickname else "",
|
|||
|
|
"enterpriseId": str(enterprise) if enterprise else "",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def account_display(account):
|
|||
|
|
acct = account.get("account") or {}
|
|||
|
|
return acct.get("nickname") or acct.get("uid") or account.get("name") or "WorkBuddy 账号"
|