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
+472
View File
@@ -0,0 +1,472 @@
#!/usr/bin/env python3
"""AgentCheckin engine: account orchestration, check-in runs, notifications."""
import json
import secrets
import time
import urllib.request
from common import (load_accounts, save_accounts, load_settings, load_state,
save_state, log, now_ts, today_str, fmt_ts, fmt_now,
public_account, USER_AGENT, _SSL_CTX)
import workbuddy
import trae
import qoder
# result statuses considered "handled" (no retry needed)
DONE_STATUSES = {"ok", "ok_unverified", "already", "inactive"}
def new_account_id(platform):
return "%s_%s" % (platform, secrets.token_hex(4))
def find_account(accounts, account_id):
for acc in accounts:
if acc.get("id") == account_id:
return acc
return None
# ---------------------------------------------------------------------------
# Account creation
# ---------------------------------------------------------------------------
def add_workbuddy_manual(name, access_token, refresh_token="", domain="codebuddy.cn"):
accounts = load_accounts()
acc = {
"id": new_account_id("wb"),
"platform": "workbuddy",
"name": name or "WorkBuddy 账号",
"enabled": True,
"created_at": fmt_now(),
"auth": {
"accessToken": access_token,
"refreshToken": refresh_token,
"expiresAt": 0,
"domain": domain,
},
"account": {},
"last_result": None,
}
accounts.append(acc)
save_accounts(accounts)
log("added workbuddy account %s (%s)" % (acc["id"], acc["name"]))
return acc
def add_workbuddy_oauth(payload):
"""payload from workbuddy.poll_oauth(): {accessToken, refreshToken, ...}."""
accounts = load_accounts()
acct_info = payload.get("account") or {}
domain = payload.get("domain") or "codebuddy.cn"
acc = {
"id": new_account_id("wb"),
"platform": "workbuddy",
"name": acct_info.get("nickname") or "WorkBuddy 账号",
"enabled": True,
"created_at": fmt_now(),
"auth": {
"accessToken": payload.get("accessToken", ""),
"refreshToken": payload.get("refreshToken", ""),
"expiresAt": payload.get("expiresAt") or 0,
"domain": domain,
},
"account": {
"uid": acct_info.get("uid", ""),
"nickname": acct_info.get("nickname", ""),
"enterpriseId": acct_info.get("enterpriseId", ""),
},
"last_result": None,
}
accounts.append(acc)
save_accounts(accounts)
log("added workbuddy account via oauth: %s (%s)" % (acc["id"], acc["name"]))
return acc
def add_trae(name, access_token, refresh_token="", device_id_str=""):
accounts = load_accounts()
acc = {
"id": new_account_id("trae"),
"platform": "trae",
"name": name or "TRAE 账号",
"enabled": True,
"created_at": fmt_now(),
"auth": {
"accessToken": access_token,
"refreshToken": refresh_token,
},
"device_id": device_id_str or trae.stable_device_id(new_account_id("trae")),
"last_result": None,
}
accounts.append(acc)
save_accounts(accounts)
log("added trae account %s (%s)" % (acc["id"], acc["name"]))
return acc
def add_qoder(name, access_token, refresh_token="", device=None, domain="openapi.qoder.com.cn"):
accounts = load_accounts()
acc = {
"id": new_account_id("qoder"),
"platform": "qoder",
"name": name or "Qoder 账号",
"enabled": True,
"created_at": fmt_now(),
"auth": {
"accessToken": access_token,
"refreshToken": refresh_token,
"expiresAt": 0,
"domain": domain,
},
"device": {k: str(v) for k, v in (device or {}).items()
if k in qoder.DEVICE_DEFAULTS},
"last_result": None,
}
accounts.append(acc)
save_accounts(accounts)
log("added qoder account %s (%s)" % (acc["id"], acc["name"]))
return acc
def delete_account(account_id):
accounts = load_accounts()
left = [a for a in accounts if a.get("id") != account_id]
save_accounts(left)
return len(left) != len(accounts)
def update_account(account_id, **fields):
accounts = load_accounts()
acc = find_account(accounts, account_id)
if not acc:
return None
for k, v in fields.items():
if k in ("name", "enabled"):
acc[k] = v
elif k == "accessToken" and v:
acc.setdefault("auth", {})["accessToken"] = v
elif k == "refreshToken" and v:
acc.setdefault("auth", {})["refreshToken"] = v
elif k == "domain" and v:
acc.setdefault("auth", {})["domain"] = v
save_accounts(accounts)
return acc
# ---------------------------------------------------------------------------
# Check-in runs
# ---------------------------------------------------------------------------
def run_account(acc, force=False):
"""Run check-in for one account. Returns result dict."""
platform = acc.get("platform")
started = fmt_now()
if platform == "workbuddy":
result = _run_workbuddy(acc)
elif platform == "trae":
result = trae.checkin_once(acc)
elif platform == "qoder":
result = qoder.checkin_once(acc)
else:
result = {"status": "fail", "message": "未知平台 %s" % platform, "points": 0}
result["time"] = started
result["finished_at"] = fmt_now()
acc["last_result"] = result
acc["last_run"] = now_ts()
if result.get("status") == "no_session":
acc["need_reauth"] = True
elif result.get("status") in DONE_STATUSES:
acc["need_reauth"] = False
_reload(acc)
return result
def _reload(acc):
"""Persist the mutated account back into the accounts file."""
accounts = load_accounts()
for i, a in enumerate(accounts):
if a.get("id") == acc.get("id"):
accounts[i] = acc
break
else:
accounts.append(acc)
save_accounts(accounts)
return accounts
def _run_workbuddy(acc):
auth = acc.get("auth", {})
if not auth.get("accessToken"):
return {"status": "no_auth", "message": "缺少 accessToken,请重新授权", "points": 0}
if workbuddy.is_global(auth):
return {"status": "inactive", "message": "Global(workbuddy.ai)账号不支持每日签到", "points": 0}
# refresh token when close to expiry
if workbuddy.token_expiring(auth) and auth.get("refreshToken"):
ok, msg = workbuddy.refresh_token(acc)
if not ok:
log("workbuddy %s token refresh: %s" % (acc.get("id"), msg), "WARN")
if "失效" in msg or "过期" in msg:
return {"status": "no_session", "message": msg, "points": 0}
else:
_reload(acc)
# status first (idempotent)
checked, detail, err = workbuddy.query_status(acc)
if err == "no_session":
return {"status": "no_session", "message": "登录态已失效,请重新授权", "points": 0}
if checked is True:
credits = None
if detail:
credits = detail.get("total_credits")
if credits is None:
summary, _ = workbuddy.fetch_credits(acc)
credits = (summary or {}).get("remain")
return {"status": "already", "message": "今日已签到", "points": 0,
"credits": credits, "detail": detail or {}}
# claim
status, message, points = workbuddy.perform_checkin(acc)
if status == "no_session":
return {"status": "no_session", "message": message, "points": 0}
if status == "ok":
# verify
for _ in range(3):
time.sleep(2)
v_checked, v_detail, v_err = workbuddy.query_status(acc)
if v_err == "no_session":
break
if v_checked is True:
credits = None
if v_detail:
credits = v_detail.get("total_credits")
if credits is None:
summary, _ = workbuddy.fetch_credits(acc)
credits = (summary or {}).get("remain")
return {"status": "ok", "message": "签到成功(已回查确认)",
"points": points, "credits": credits, "detail": v_detail or {}}
if v_checked is False:
break
return {"status": "ok_unverified", "message": "领取成功(+%s)但回查未确认" % points,
"points": points}
return {"status": status, "message": message, "points": points}
def run_all(account_id=None, reason="manual"):
"""Run check-in for all enabled accounts (or one). Returns summary dict."""
accounts = load_accounts()
targets = [a for a in accounts if a.get("enabled")]
if account_id:
targets = [a for a in targets if a.get("id") == account_id]
results = {}
for acc in targets:
try:
res = run_account(acc)
except Exception as e: # never let one account kill the run
res = {"status": "fail", "message": "内部错误:%s" % e, "points": 0,
"time": fmt_now(), "finished_at": fmt_now()}
acc["last_result"] = res
_reload(acc)
results[acc["id"]] = {
"platform": acc.get("platform"),
"name": acc.get("name"),
"status": res.get("status"),
"message": res.get("message"),
"points": res.get("points", 0),
"credits": res.get("credits"),
}
log("checkin[%s] %s (%s): %s %s" % (reason, acc.get("id"), acc.get("platform"),
res.get("status"), res.get("message")))
# record today's summary in state (always, even with zero accounts, so the
# daemon's startup catch-up does not repeat when no accounts exist)
state = load_state()
today = today_str()
state.setdefault("today", {})[today] = results
state["last_run"] = fmt_now()
state["last_run_reason"] = reason
# prune history: keep the last 30 days
days = sorted(state.get("today", {}).keys())
for old in days[:-30]:
state["today"].pop(old, None)
save_state(state)
if results:
notify(results, reason)
return results
def test_account(account_id):
"""Query status + credits without claiming."""
accounts = load_accounts()
acc = find_account(accounts, account_id)
if not acc:
return {"error": "账号不存在"}
out = {"id": account_id, "platform": acc.get("platform")}
try:
if acc.get("platform") == "workbuddy":
if workbuddy.token_expiring(acc.get("auth", {})) and acc["auth"].get("refreshToken"):
ok, msg = workbuddy.refresh_token(acc)
_reload(acc)
out["refresh"] = msg
checked, detail, err = workbuddy.query_status(acc)
out["checked_in"] = checked
out["error"] = err
summary, cerr = workbuddy.fetch_credits(acc)
if summary:
out["credits"] = summary
if cerr and cerr != "no_session":
out["credits_error"] = cerr
elif acc.get("platform") == "trae":
checked, enable, credits, err = trae.query_status(acc)
out["checked_in"] = checked
out["enable"] = enable
out["credits"] = credits
out["error"] = err
elif acc.get("platform") == "qoder":
ok, msg = qoder.ensure_token(acc)
_reload(acc)
if ok:
out["refresh"] = msg
# 探测两个站点,判断 token 属于哪个域(两站 Token 不互通)
probe = qoder.probe_domains(acc)
out["domain_probe"] = probe
current = (acc.get("auth") or {}).get("domain")
ok_domain = None
for dom, r in probe.items():
if r.get("auth"):
ok_domain = dom
out["token_domain"] = ok_domain
out["domain_match"] = (ok_domain == current) if ok_domain else None
if ok_domain and ok_domain != current:
out["hint"] = "Token 属于 %s,但当前域名是 %s,请切换" % (ok_domain, current)
campaigns, err = qoder.query_campaigns(acc)
if err:
out["error"] = err
else:
benefits = qoder._benefits(campaigns)
claimable = [c for c in benefits if c.get("claimStatus") == "CLAIMABLE"]
out["claimable"] = len(claimable)
out["campaigns"] = len(benefits)
amounts = []
for c in claimable:
b = c.get("benefit") or {}
if isinstance(b, dict) and b.get("amount"):
amounts.append(b["amount"])
if amounts:
out["credits"] = sum(amounts)
except Exception as e:
out["error"] = str(e)
return out
# ---------------------------------------------------------------------------
# Notifications (multi-platform webhook)
# ---------------------------------------------------------------------------
WEBHOOK_TYPES = ("auto", "wecom", "dingtalk", "feishu", "bark", "generic")
def _detect_webhook_type(url):
u = url.lower()
if "qyapi.weixin.qq.com" in u:
return "wecom"
if "oapi.dingtalk.com" in u:
return "dingtalk"
if "open.feishu.cn" in u or "open.larkoffice.com" in u:
return "feishu"
if "api.day.app" in u or "bark" in u:
return "bark"
return "generic"
def notify_payload(wtype, url, title, content):
"""Build the platform-correct request. Returns (final_url, body_or_None).
body=None means a plain GET (Bark URL-path API).
"""
if wtype == "auto" or wtype not in WEBHOOK_TYPES:
wtype = _detect_webhook_type(url)
text = title + "\n" + content
if wtype in ("wecom", "dingtalk"):
# 企业微信 / 钉钉自定义机器人: {"msgtype":"text","text":{"content":...}}
return url, json.dumps({"msgtype": "text", "text": {"content": text}},
ensure_ascii=False).encode("utf-8")
if wtype == "feishu":
# 飞书自定义机器人: {"msg_type":"text","content":{"text":...}}
return url, json.dumps({"msg_type": "text", "content": {"text": text}},
ensure_ascii=False).encode("utf-8")
if wtype == "bark":
# Bark: <base>/<title>/<body>(URL 路径传参,GET 即可)
import urllib.parse
base = url.rstrip("/")
return "%s/%s/%s" % (base, urllib.parse.quote(title), urllib.parse.quote(content)), None
# Server酱 / PushPlus / 通用: 尽量多带常见字段
return url, json.dumps({"title": title, "content": content, "text": text,
"msgtype": "text", "desp": content},
ensure_ascii=False).encode("utf-8")
def notify(results, reason):
settings = load_settings()
url = (settings.get("webhook_url") or "").strip()
if not url:
return
notify_on = settings.get("notify_on") or []
has_fail = any(r["status"] in ("fail", "no_session", "no_auth", "retry_later")
for r in results.values())
has_success = any(r["status"] in ("ok", "ok_unverified") for r in results.values())
if has_fail and "fail" not in notify_on:
if not (has_success and "success" in notify_on):
return
if not has_fail and not has_success:
return
if not has_fail and has_success and "success" not in notify_on:
return
lines = []
for r in results.values():
mark = {"ok": "✅", "ok_unverified": "✅", "already": "☑️",
"inactive": "➖", "fail": "❌", "no_session": "🔒",
"no_auth": "🔒", "retry_later": "⏳"}.get(r["status"], "•")
lines.append("%s %s %s:%s" % (mark, r.get("platform", ""), r.get("name", ""), r.get("message", "")))
content = "\n".join(lines)
title = "Agent 签到结果(%s)" % today_str()
wtype = settings.get("webhook_type") or "auto"
final_url, body = notify_payload(wtype, url, title, content)
try:
req = urllib.request.Request(final_url, data=body, method="POST" if body else "GET")
if body:
req.add_header("Content-Type", "application/json")
req.add_header("User-Agent", USER_AGENT)
with urllib.request.urlopen(req, timeout=15, context=_SSL_CTX) as resp:
resp.read()
log("webhook notification sent via %s (%d bytes)" % (wtype, len(body or 0)))
except Exception as e:
log("webhook notification failed (%s): %s" % (wtype, e), "WARN")
# ---------------------------------------------------------------------------
# Dashboard state
# ---------------------------------------------------------------------------
def dashboard():
accounts = load_accounts()
settings = load_settings()
state = load_state()
today = today_str()
today_results = (state.get("today") or {}).get(today, {})
items = []
for acc in accounts:
item = public_account(acc)
item["today"] = today_results.get(acc.get("id"))
item["need_reauth"] = bool(acc.get("need_reauth"))
item["disabled"] = bool(acc.get("disabled"))
items.append(item)
return {
"accounts": items,
"settings": settings,
"today": today,
"today_results": today_results,
"last_run": state.get("last_run"),
"last_run_reason": state.get("last_run_reason"),
"next_run_at": state.get("next_run_at"),
"daemon": state.get("daemon"),
}