#!/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: //<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"), }