核心发现(实测): - 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 前端,全部通过
482 lines
18 KiB
Python
482 lines
18 KiB
Python
#!/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"),
|
||
}
|