- 原生 fnOS 应用(CGI 入口 + Python 守护进程),依赖 python312 运行时 - 三平台签到:TRAE 每日 200 积分、WorkBuddy 7 天循环、Qoder 每日 100 Credits - WorkBuddy 网页授权(OAuth 设备流)、TRAE icubeAuth 解密 Token、Qoder Cosy 设备头 - 多时段调度(默认 00:05/09:00/12:30/19:00)、启动补跑、幂等签到+回查确认 - 多平台 Webhook 通知(企业微信/钉钉/飞书/Bark/Server酱/PushPlus 自动识别) - Qoder 双站点 Token 隔离自动探测与修复 - 前端:仪表盘/账号管理/签到记录/设置四个页签,30s 轮询+变化检测+隐藏暂停 - 测试:34 后端 + 25 E2E + 9 前端回归 + 真实时钟调度测试
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 账号"
|