Files
fnos-agent-checkin/AgentCheckin/app/backend/engine.py
T
cbb c263edc3e7 feat(qoder): 适配国际版每日签到 — UMID 设备风控身份 + 国际版活动 schema
核心发现(实测):
- Qoder 国际版服务端只对带 Cosy-MachineToken/Code/Type 的请求下发签到活动,
  否则 campaigns 返回空数组(国内版无此限制)
- 国际版活动 actionType=VIEW_DETAILS(国内版 CLAIM_BENEFIT),claimStatus 一致
- 活动窗口 2026-09-01~10-31,每日 10:00 (UTC+8) 刷新,领取后 30 天有效

实现:
- 新增 umid.py:从官方 npm 包 @qoder-ai/qodercli 下载并提取内嵌 UMID 二进制
  (x86_64/arm64 ELF),试跑校验后缓存到 @appdata/AgentCheckin/umid/
- qoder.py:签到前自动确保风控身份,支持国际版/国内版双 schema 活动解析
- API:/api/umid/status、/api/umid/install、/api/accounts/refresh_risk
- 前端:设置页风控身份面板(下载/状态)、账号列表「刷新风控」按钮
- 诊断:测连接返回 risk_identity / campaigns_debug(原始字段)

验证:NAS 实测 Qoder 国际版从「活动列表为空」变为「今日已领取(75 积分)」
测试:37 后端 + 25 E2E + 9 前端,全部通过
2026-10-19 12:04:44 +08:00

482 lines
18 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 不互通)
# 风控身份(国际版拿活动的前提)
tok, code, mtype, rerr = qoder.risk_identity(acc)
out["risk_identity"] = {
"ok": bool(tok),
"error": rerr,
"machineToken": (tok[:12] + "…") if tok else None,
}
_reload(acc)
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)
out["campaigns_debug"] = qoder.campaigns_debug(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"),
}