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,362 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AgentCheckin shared utilities: paths, HTTP, atomic JSON storage, logging.
|
||||
|
||||
All persistent data lives under TRIM_PKGVAR (/vol{n}/@appdata/AgentCheckin).
|
||||
Falls back to a local ./data directory when running outside fnOS (dev/test).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import fcntl
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import ssl
|
||||
|
||||
APP_NAME = "AgentCheckin"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
APP_NAME = "AgentCheckin"
|
||||
|
||||
|
||||
def _volume_root():
|
||||
"""Volume root of the install layout.
|
||||
|
||||
@appcenter layout: /vol{n}/@appcenter/{app}/backend -> /vol{n}
|
||||
(three levels up from the backend directory)
|
||||
"""
|
||||
p = os.path.dirname(os.path.abspath(__file__))
|
||||
for _ in range(3):
|
||||
p = os.path.dirname(p)
|
||||
return p
|
||||
|
||||
|
||||
def _candidate_dirs():
|
||||
"""All plausible persistent-data locations across fnOS install layouts."""
|
||||
here = os.path.dirname(os.path.abspath(__file__)) # .../{app}/backend
|
||||
vol = _volume_root()
|
||||
cands = [
|
||||
# documented layout: /var/apps/{app}/var -> /vol{n}/@appdata/{app}
|
||||
"/var/apps/%s/var" % APP_NAME,
|
||||
# @appcenter layout: {volume}/@appcenter/{app} -> {volume}/@appdata/{app}
|
||||
os.path.join(vol, "@appdata", APP_NAME),
|
||||
# well-known volume data dirs (in case the app dir layout differs)
|
||||
"/vol1/@appdata/%s" % APP_NAME,
|
||||
"/vol2/@appdata/%s" % APP_NAME,
|
||||
# relative fallbacks for manual unpacking
|
||||
os.path.join(here, "..", "..", "var"),
|
||||
os.path.join(here, "..", "var"),
|
||||
]
|
||||
return [os.path.abspath(c) for c in cands]
|
||||
|
||||
|
||||
def _data_dir():
|
||||
# 1. explicit override (set by cmd/main and index.cgi so the daemon and
|
||||
# the CGI always agree on one location)
|
||||
explicit = os.environ.get("AGENTCHECKIN_DATA_DIR", "")
|
||||
if explicit:
|
||||
return os.path.abspath(explicit)
|
||||
if os.environ.get("AGENTCHECKIN_DEV"):
|
||||
base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "devdata")
|
||||
return os.path.abspath(base)
|
||||
# 2. fnOS lifecycle env (set for cmd/main; often absent for CGI processes)
|
||||
env = os.environ.get("TRIM_PKGVAR", "")
|
||||
if env and os.path.isdir(env):
|
||||
return env
|
||||
# 3. first existing candidate
|
||||
for cand in _candidate_dirs():
|
||||
if os.path.isdir(cand):
|
||||
return cand
|
||||
# 4. nothing exists yet: use the @appdata convention on the same volume
|
||||
return os.path.abspath(os.path.join(_volume_root(), "@appdata", APP_NAME))
|
||||
|
||||
|
||||
DATA_DIR = _data_dir()
|
||||
ACCOUNTS_FILE = os.path.join(DATA_DIR, "accounts.json")
|
||||
SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json")
|
||||
STATE_FILE = os.path.join(DATA_DIR, "state.json")
|
||||
TRIGGER_FILE = os.path.join(DATA_DIR, "trigger")
|
||||
PID_FILE = os.path.join(DATA_DIR, "daemon.pid")
|
||||
LOG_DIR = os.path.join(DATA_DIR, "logs")
|
||||
LOG_FILE = os.path.join(LOG_DIR, "checkin.log")
|
||||
|
||||
USER_AGENT = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
|
||||
|
||||
_SSL_CTX = ssl.create_default_context()
|
||||
_SSL_CTX.check_hostname = False
|
||||
_SSL_CTX.verify_mode = ssl.CERT_NONE
|
||||
|
||||
|
||||
def ensure_dirs():
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Time helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def now_ts():
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def today_str():
|
||||
return time.strftime("%Y-%m-%d", time.localtime())
|
||||
|
||||
|
||||
def fmt_ts(ts):
|
||||
if not ts:
|
||||
return ""
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts))
|
||||
|
||||
|
||||
def fmt_now():
|
||||
return fmt_ts(now_ts())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging (append to checkin.log; never log tokens)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def log(msg, level="INFO"):
|
||||
line = "[%s] [%s] %s\n" % (fmt_now(), level, msg)
|
||||
try:
|
||||
ensure_dirs()
|
||||
# simple rotation: keep one previous generation, cap at ~2 MiB
|
||||
try:
|
||||
if os.path.exists(LOG_FILE) and os.path.getsize(LOG_FILE) > 2 * 1024 * 1024:
|
||||
os.replace(LOG_FILE, LOG_FILE + ".1")
|
||||
except OSError:
|
||||
pass
|
||||
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
except OSError:
|
||||
pass
|
||||
return line
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def http_post_json(url, body=None, headers=None, timeout=20):
|
||||
"""POST JSON. Returns (data, http_status, error).
|
||||
|
||||
On success data is the parsed JSON (envelope or raw). On failure data is
|
||||
None and error describes the problem. HTTP error bodies that carry a
|
||||
{code,msg} envelope are surfaced through error as 'code=.. msg=..'.
|
||||
"""
|
||||
payload = json.dumps(body if body is not None else {}).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=payload, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("Accept", "application/json")
|
||||
req.add_header("User-Agent", USER_AGENT)
|
||||
if headers:
|
||||
for k, v in headers.items():
|
||||
req.add_header(k, v)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout, context=_SSL_CTX) as resp:
|
||||
status = resp.status
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = ""
|
||||
try:
|
||||
raw = e.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
env = json.loads(raw)
|
||||
if isinstance(env, dict) and env.get("code", 0) != 0:
|
||||
return None, e.code, "code=%s msg=%s" % (env.get("code"), env.get("msg", ""))
|
||||
if isinstance(env, dict):
|
||||
return env, e.code, None
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
pass
|
||||
return None, e.code, "HTTP %s: %s" % (e.code, raw[:200])
|
||||
except urllib.error.URLError as e:
|
||||
return None, 0, "network error: %s" % (getattr(e, "reason", e))
|
||||
except Exception as e: # timeout, reset, ...
|
||||
return None, 0, "request failed: %s" % e
|
||||
|
||||
try:
|
||||
env = json.loads(raw)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return None, status, "non-JSON response: %s" % raw[:200]
|
||||
return env, status, None
|
||||
|
||||
|
||||
def http_get(url, headers=None, timeout=20):
|
||||
"""GET. Returns (parsed_json_or_None, http_status, error)."""
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
req.add_header("Accept", "application/json")
|
||||
req.add_header("User-Agent", USER_AGENT)
|
||||
if headers:
|
||||
for k, v in headers.items():
|
||||
req.add_header(k, v)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout, context=_SSL_CTX) as resp:
|
||||
status = resp.status
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = ""
|
||||
try:
|
||||
raw = e.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
env = json.loads(raw)
|
||||
if isinstance(env, dict):
|
||||
return env, e.code, None
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
pass
|
||||
return None, e.code, "HTTP %s: %s" % (e.code, raw[:200])
|
||||
except urllib.error.URLError as e:
|
||||
return None, 0, "network error: %s" % (getattr(e, "reason", e))
|
||||
except Exception as e:
|
||||
return None, 0, "request failed: %s" % e
|
||||
try:
|
||||
return json.loads(raw), status, None
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return None, status, "non-JSON response: %s" % raw[:200]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Atomic JSON storage with flock
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _locked(path, mode="r"):
|
||||
"""Open path with an exclusive flock held for the lifetime of the handle.
|
||||
|
||||
Returns (fileobj, lock_path). Caller must close the fileobj to release.
|
||||
"""
|
||||
lock_path = path + ".lock"
|
||||
lf = open(lock_path, "a+")
|
||||
fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
|
||||
if mode == "r" and not os.path.exists(path):
|
||||
return None, lf
|
||||
f = open(path, mode, encoding="utf-8")
|
||||
return f, lf
|
||||
|
||||
|
||||
def read_json(path, default):
|
||||
try:
|
||||
f, lf = _locked(path, "r")
|
||||
except OSError:
|
||||
return default
|
||||
try:
|
||||
if f is None:
|
||||
return default
|
||||
try:
|
||||
return json.load(f)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return default
|
||||
finally:
|
||||
if f is not None:
|
||||
f.close()
|
||||
lf.close()
|
||||
|
||||
|
||||
def write_json(path, obj, private=True):
|
||||
ensure_dirs()
|
||||
tmp = "%s.tmp.%d" % (path, os.getpid())
|
||||
data = json.dumps(obj, ensure_ascii=False, indent=2)
|
||||
f, lf = _locked(path, "a")
|
||||
try:
|
||||
with open(tmp, "w", encoding="utf-8") as t:
|
||||
t.write(data)
|
||||
t.flush()
|
||||
os.fsync(t.fileno())
|
||||
os.replace(tmp, path)
|
||||
if private:
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
f.close()
|
||||
lf.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings / accounts / state accessors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_SETTINGS = {
|
||||
"schedule_times": ["00:05", "09:00", "12:30", "19:00"],
|
||||
"webhook_url": "",
|
||||
"webhook_type": "auto", # auto | wecom | dingtalk | feishu | bark | generic
|
||||
"notify_on": ["fail"], # subset of ["success", "fail"]
|
||||
}
|
||||
|
||||
|
||||
def load_settings():
|
||||
s = read_json(SETTINGS_FILE, None)
|
||||
if not isinstance(s, dict):
|
||||
s = {}
|
||||
out = dict(DEFAULT_SETTINGS)
|
||||
out.update({k: v for k, v in s.items() if k in DEFAULT_SETTINGS})
|
||||
times = [t for t in out.get("schedule_times", []) if isinstance(t, str) and len(t) == 5 and t[2] == ":"]
|
||||
out["schedule_times"] = times or list(DEFAULT_SETTINGS["schedule_times"])
|
||||
notify = [n for n in out.get("notify_on", []) if n in ("success", "fail")]
|
||||
out["notify_on"] = notify
|
||||
return out
|
||||
|
||||
|
||||
def save_settings(settings):
|
||||
write_json(SETTINGS_FILE, settings)
|
||||
|
||||
|
||||
def load_accounts():
|
||||
data = read_json(ACCOUNTS_FILE, {"accounts": []})
|
||||
if isinstance(data, dict) and isinstance(data.get("accounts"), list):
|
||||
return data["accounts"]
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return []
|
||||
|
||||
|
||||
def save_accounts(accounts):
|
||||
write_json(ACCOUNTS_FILE, {"accounts": accounts})
|
||||
|
||||
|
||||
def load_state():
|
||||
s = read_json(STATE_FILE, {})
|
||||
return s if isinstance(s, dict) else {}
|
||||
|
||||
|
||||
def save_state(state):
|
||||
write_json(STATE_FILE, state, private=False)
|
||||
|
||||
|
||||
def mask_token(tok):
|
||||
if not tok or not isinstance(tok, str):
|
||||
return ""
|
||||
if len(tok) <= 10:
|
||||
return "***"
|
||||
return tok[:6] + "***" + tok[-4:]
|
||||
|
||||
|
||||
def public_account(acc):
|
||||
"""Account view for the UI: never expose raw tokens."""
|
||||
a = dict(acc)
|
||||
auth = dict(acc.get("auth", {}))
|
||||
if "accessToken" in auth:
|
||||
auth["accessToken"] = mask_token(auth.get("accessToken"))
|
||||
if "refreshToken" in auth:
|
||||
auth["refreshToken"] = mask_token(auth.get("refreshToken"))
|
||||
a["auth"] = auth
|
||||
return a
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# tiny self-check helper
|
||||
print("data dir:", DATA_DIR)
|
||||
print("exists:", os.path.isdir(DATA_DIR))
|
||||
Reference in New Issue
Block a user