feat: AgentCheckin v1.2.2 — fnOS 应用:TRAE/WorkBuddy/Qoder 三平台每日积分自动签到

- 原生 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 前端回归 + 真实时钟调度测试
This commit is contained in:
cbb
2026-10-13 19:09:48 +08:00
commit a3b84d5ce2
39 changed files with 5164 additions and 0 deletions
+326
View File
@@ -0,0 +1,326 @@
#!/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 账号"