Files
fnos-agent-checkin/AgentCheckin/app/backend/engine.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

473 lines
17 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
"""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"),
}