Files
fnos-agent-checkin/AgentCheckin/app/backend/workbuddy.py
T
cbb a3b84d5ce2 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 前端回归 + 真实时钟调度测试
2026-10-13 19:09:48 +08:00

327 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 账号"