#!/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 账号"