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:
@@ -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"),
|
||||
}
|
||||
Reference in New Issue
Block a user