Files
shirobana-bot/bot-utility/ginkgostu.py
T
RRRosmontis 34089d0042 Initial import of Shirobana bot backend
Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
2026-11-11 00:25:30 +08:00

2769 lines
116 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.
# -*- coding: utf-8 -*-
# ginkgostu.py — 东北财经大学·白果云课堂 通知轮询 / 推送 / 自动签到(单文件合并模块)
#
# 本文件由以下四个模块合并而成(2026-09-18),功能不变:
# ginkgo_errors.py -> 「限流错误分类」一节
# ginkgo_delivery.py -> 「持久化投递 / 账号状态存储」一节
# ginkgostu_signin.py -> 「签到 URL 解析 / 签到接口」一节(AES/SSO 与轮询登录共用一份)
# ginkgostu.py -> 主体(常量 / AES / SSO 登录 / 轮询 / 指令 / 调度)
# 合并后自动签到固定走进程内本地 SSO + 签到,openapi 远程回退接口已移除。
#
# 指令(QQ 与 TG 通用,无需管理员)
# .ginkgo help 帮助
# .ginkgo rule 查看轮询规则
# .ginkgo bind <账号> <密码> 绑定账号(成功后返回学生姓名)
# .ginkgo unbind [账号] 解绑账号
# .ginkgo status 查看绑定账号与开关状态
# .ginkgo sign 强制执行最新有效签到活动
# .ginkgo notice <int> 查看最新 N 条通知(两类合并)
# .ginkgo notice <on|off> 开关通知推送(关闭且自动签到也关时停止轮询;
# 仅开自动签到时只在 10 秒档时段轮询;两个都关则
# 只每天 11:55 / 23:55 保活探活一次)
# .ginkgo auto-sign-in <on|off> 开关自动签到
#
# 状态:本目录下 ginkgostu/<学号>.json(账号密码明文保存,按需求)
# 依赖:requests;AES-128 为自带纯 Python 实现(不引入第三方加密库)
# 限流:检测到 HTTP 429 或应用层限流提示时,自动关闭该账号的推送与自动签到并通知用户;
# 轮询按账号并发执行(上限 ACCOUNT_CONCURRENCY),慢账号不阻塞其它账号。
#
# ── 协议(2026-09-14 在 bj 实测跑通) ────────────────────────────────────────
# 白果云 SPA 自带登录已被关闭(GET /student/getLoginMethodSet: normalAuthLogin=0),
# 必须走 SSO OAuth 授权码流程:
# 1) GET /student/getLoginUnifySet -> loginUrl / clientId / rollBackUrl / responseType
# 2) GET <loginUrl>?response_type=code&client_id=..&redirect_uri=<rollBackUrl>
# -> SSO 登录页,页面含 __token__(CSRF)
# 3) POST /auth/widget?layer=image_verify&widget=Slider&action=get_verify&type=login_image_verify
# 服务端直接把滑块答案下发:data.d 用 data.token 前 16 字节作 AES-128-ECB 密钥
# 解密即得 x(配合 data.offset 容错)——无需图像识别
# 4) POST SSO 登录页: username / password(AES-128-CBC+ZeroPadding, key=iv=__token__ 前 16 字节)
# verify_token / verify_code(x) / fingerprint / __token__
# -> 302,Location 形如 https://ginkgostu.dufe.edu.cn/login?code=xxx&state=...
# 5) POST /student/authLogin {code} -> data.token / data.studentName
# 取数(请求头带 token,可附随机 fingerprint):
# 通知类: GET /student/message/getMessageAll?pageSize=20&pageNo=1&readFlag=0 (0=全部)
# 互动消息: POST /student/busiMessage/list body {} (空=全部,含已读)
# 自动签到:进程内直接跑 SSO 登录 + getSignInInfo + InTheSign(签到代码源自
# hk openapi 后端 ginkgo_sign_in.py 的移植,与本文件轮询登录共用 AES/SSO 实现)。
import asyncio
import base64
import copy
import html as _html
import json
import os
import re
import tempfile
import threading
import time
import uuid
import uuid as _uuid
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone, time as dtime
from email.utils import parsedate_to_datetime
from html.parser import HTMLParser
from urllib.parse import urlsplit
from functools import wraps
import weakref
import requests
import identity
from channel import get_channel, send_message, push_to_user
# ==================== 限流错误分类(原 ginkgo_errors.py) ====================
class RateLimitedError(Exception):
"""An upstream explicitly refused requests due to throttling."""
_RATE_MESSAGE = re.compile(
r"too\s+many\s+requests|rate[\s_-]*limit(?:ed|ing|\s+exceeded)?|"
r"request(?:s)?\s+throttl|throttl(?:ed|ing)|"
r"(?:请求|访问|操作|登录|登陆|接口|提交).{0,12}(?:过于频繁|太频繁|频繁|频率.{0,6}(?:超|限制))|"
r"(?:限流|请求次数.{0,8}(?:超限|超出|超过)|访问次数.{0,8}(?:超限|超出|超过))",
re.I,
)
_RATE_CODES = {"429", "4290", "40029", "90029", "RATE_LIMIT", "RATE_LIMITED", "RATE_LIMIT_EXCEEDED",
"TOO_MANY_REQUESTS", "REQUEST_TOO_FREQUENT", "THROTTLED",
"THROTTLING", "FREQUENT_REQUESTS"}
_FIELDS = ("code", "msg", "message", "error")
def _status(value):
try:
return int(value)
except (ValueError, TypeError):
return 0
def _explicit(value, depth=0):
if depth > 3:
return False
if isinstance(value, dict):
for key in _FIELDS:
item = value.get(key)
if key == "code" and isinstance(item, (str, int)):
if str(item).strip().upper().replace("-", "_") in _RATE_CODES:
return True
elif isinstance(item, dict):
if _explicit(item, depth + 1):
return True
elif isinstance(item, str) and _RATE_MESSAGE.search(item[:1024]):
return True
return False
class _LoginErrors(HTMLParser):
"""Extract only explicitly marked login error elements; ignore scripts/notices."""
def __init__(self):
super().__init__()
self.stack = []
self.messages = []
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
marker = (attrs.get("id", "") + " " + attrs.get("class", "")).lower()
explicit = bool(re.search(r"(?:^|[\s_-])(?:error|errors|errormsg|errormessage)(?:$|[\s_-])", marker))
ignored = tag in ("script", "style", "template") or bool(self.stack and self.stack[-1][2])
active = explicit or bool(self.stack and self.stack[-1][1])
if tag not in ("input", "br", "img", "meta", "link", "hr", "wbr", "source"):
self.stack.append((tag, active, ignored))
def handle_endtag(self, tag):
for index in range(len(self.stack) - 1, -1, -1):
if self.stack[index][0] == tag:
del self.stack[index:]
break
def handle_data(self, data):
if self.stack and self.stack[-1][1] and not self.stack[-1][2]:
self.messages.append(data)
def is_rate_limited(status, payload=None, text=""):
"""Check HTTP status and explicit error fields, never arbitrary payload data/HTML."""
status = _status(status)
if status == 429 or _explicit(payload):
return True
# Plain response bodies are evidence only on HTTP errors, and must be short.
if status >= 400 and isinstance(text, str):
plain = text.strip()
if len(plain) <= 1024 and not re.search(r"<[^>]+>", plain):
return bool(_RATE_MESSAGE.search(plain))
return False
def _valid_retry_after(value):
if not isinstance(value, str) or not value.strip():
return False
value = value.strip()
if re.fullmatch(r"[0-9]+", value):
return True
try:
return parsedate_to_datetime(value).tzinfo is not None
except (ValueError, TypeError, OverflowError):
return False
def check_response(response, what="upstream"):
"""Return response unchanged, or raise RateLimitedError before caller parsing.
`what` is deliberately not interpolated: URLs and upstream bodies may contain
credentials. Safe to use as a requests response hook through a small lambda.
"""
if response is None:
return response
status = _status(getattr(response, "status_code", 0))
message = "上游请求过于频繁,请稍后重试"
if status == 429:
raise RateLimitedError(message)
headers = getattr(response, "headers", {}) or {}
retry_after = next((v for k, v in headers.items() if str(k).lower() == "retry-after"), None)
if status >= 400 and _valid_retry_after(retry_after):
raise RateLimitedError(message)
try:
payload = response.json()
except (ValueError, TypeError, AttributeError):
payload = None
text = getattr(response, "text", "") or ""
if is_rate_limited(status, payload, text):
raise RateLimitedError(message)
# Login pages can return HTTP 200 with an explicit error element. Never scan
# the whole successful HTML document, inline scripts, or announcement text.
if payload is None and isinstance(text, str) and "<" in text:
parser = _LoginErrors()
try:
parser.feed(text[:262144])
except (ValueError, TypeError):
pass
if _RATE_MESSAGE.search(" ".join(parser.messages)[:1024]):
raise RateLimitedError(message)
return response
def safe_error(exception):
"""Propagate explicit throttling, otherwise return a credential-free diagnostic.
Never stringify arbitrary exceptions: requests URLs, query strings, tokens,
passwords and JSON decode snippets can all include secrets.
"""
if isinstance(exception, RateLimitedError):
raise exception
response = getattr(exception, "response", None)
if response is not None:
check_response(response, "request error")
status = _status(getattr(response, "status_code", 0))
if status:
return "upstream HTTP %d" % status
return "upstream request or response error (%s)" % type(exception).__name__
# ==================== 持久化投递 / 账号状态存储(原 ginkgo_delivery.py) ====================
MAX_SIGNIN_ATTEMPTS = 6
SIGNIN_BACKOFF = (3, 3, 3, 3, 3)
LEASE_SECONDS = 600
AUDIT_CAP = 2000
TERMINAL_CAP = 1000
# 单条 outbox 的投递尝试上限。达到后置终态 failed,停止无限重试
# (避免一个永远失败的目标让队列永久 pending)。
MAX_DELIVERY_ATTEMPTS = 10
# outbox 终态:sent=已投递完所有目标;failed=尝试次数用尽仍未投递完
OUTBOX_TERMINAL = ("sent", "failed")
_LOCKS = {}
_LOCKS_GUARD = threading.Lock()
def target_key(channel_name, target_id):
"""推送目标在 outbox 行内的稳定标识(用于逐目标成功集合 sent_targets)"""
return "%s:%s" % (channel_name, target_id)
def backoff(attempts):
return min(3600, 5 * (2 ** min(max(int(attempts) - 1, 0), 10)))
def audit(data, event, key, **fields):
data.setdefault("delivery_audit", []).append(
dict(at=time.time(), event=event, key=str(key), **fields))
data["delivery_audit"] = data["delivery_audit"][-AUDIT_CAP:]
def prune_terminal(data):
# Pending records are never evicted. seen_keys remain an exact unique ledger
# so evicting terminal delivery history cannot turn old discoveries into new.
for field, terminal in (("outbox", set(OUTBOX_TERMINAL)),
("signin_jobs", {"succeeded", "failed", "cancelled"})):
records = data.get(field, {})
done = sorted((key for key, row in records.items() if row.get("status") in terminal),
key=lambda key: records[key].get("finished_at") or records[key].get("sent_at") or 0)
for key in done[:-TERMINAL_CAP]:
del records[key]
def enqueue(data, key, kind, text, now=None):
now = time.time() if now is None else now
outbox = data.setdefault("outbox", {})
if key not in outbox:
outbox[key] = {"key": key, "kind": kind, "text": str(text),
"status": "pending", "attempts": 0, "sent_targets": [],
"created_at": now, "next_attempt_at": now}
audit(data, "outbox_enqueued", key, kind=kind)
return outbox[key]
def signin_source(job):
"""Legacy jobs had no source and therefore retain automatic semantics."""
return "manual" if isinstance(job, dict) and job.get("source") == "manual" else "auto"
def signin_enabled(data, job):
return signin_source(job) == "manual" or bool(data.get("auto_sign_in"))
def _find_signin_job(jobs, activity_key, item):
"""Find new stable activity ids and legacy message-key jobs without overlap."""
if activity_key in jobs:
return activity_key, jobs[activity_key]
link = str((item or {}).get("link") or "")
for key, row in jobs.items():
if row.get("activity_key") == activity_key:
return key, row
if link and str((row.get("item") or {}).get("link") or "") == link:
return key, row
return None, None
def enqueue_signin(data, item, source="auto", now=None, run_id=None):
"""Create/reset one activity job while preserving terminal history in audit."""
now = time.time() if now is None else now
source = "manual" if source == "manual" else "auto"
item = copy.deepcopy(item if isinstance(item, dict) else {})
activity_key = str(item.get("signin_activity_key") or item.get("key") or "")
if not activity_key:
return "invalid", None, None
jobs = data.setdefault("signin_jobs", {})
key, old = _find_signin_job(jobs, activity_key, item)
if old:
status = old.get("status")
if status in ("pending", "running"):
if source == "manual" and signin_source(old) == "auto":
old.update(source="manual", activity_key=activity_key)
old.setdefault("run_id", run_id or uuid.uuid4().hex)
audit(data, "manual_signin_promoted", key, status=status,
attempts=old.get("attempts", 0), run_id=old.get("run_id"))
return "active", key, old
if status == "succeeded":
return "succeeded", key, old
if source != "manual" or status not in ("failed", "cancelled"):
return "terminal", key, old
audit(data, "manual_signin_reset", key, old_status=status,
old_attempts=old.get("attempts", 0), old_reason=str(old.get("reason") or "")[:300],
old_run_id=old.get("run_id"))
else:
key = activity_key
job = {"key": key, "activity_key": activity_key, "item": item,
"source": source, "run_id": run_id or uuid.uuid4().hex,
"status": "pending", "attempts": 0,
"created_at": now, "next_attempt_at": now}
jobs[key] = job
audit(data, "signin_enqueued", key, source=source, run_id=job["run_id"])
return ("reset" if old else "created"), key, job
def _signin_run_token(job):
return str(job.get("run_id") or "legacy")
def retry_receipt_key(key, job, attempt):
return "signin-retry:%s:%s:%s" % (key, _signin_run_token(job), int(attempt))
def signin_type_name(item=None, payload=None):
item = item if isinstance(item, dict) else {}
payload = payload if isinstance(payload, dict) else {}
name = payload.get("typeName") or item.get("signin_type_name")
if name:
return str(name)
raw = payload.get("type") if payload.get("type") is not None else item.get("signin_type")
try:
signin_type = int(raw)
except (TypeError, ValueError):
return "未知签到类型"
return {0: "二维码签到", 1: "数字码签到", 2: "普通签到", 3: "直播签到"}.get(
signin_type, "未知签到类型(%s)" % signin_type)
def retry_receipt_text(job, reason, attempt):
job = job if isinstance(job, dict) else {}
item = job.get("item") if isinstance(job.get("item"), dict) else {}
title = str(item.get("title") or "").strip()
lines = ["【白果云·%s签到进度】" % ("手动" if signin_source(job) == "manual" else "自动")]
if title:
lines.append("活动:%s" % title[:120])
lines.append("签到类型:%s" % signin_type_name(item))
lines.append("第 %d/%d 次尝试失败" % (attempt, MAX_SIGNIN_ATTEMPTS))
lines.append("原因:%s" % str(reason or "未返回明确提交成功结果")[:300])
lines.append("%d秒后进行第 %d/%d 次" % (
SIGNIN_BACKOFF[attempt - 1], attempt + 1, MAX_SIGNIN_ATTEMPTS))
lines.append("链接:%s" % str(item.get("link") or item.get("jump") or ""))
return "\n".join(lines)
def enqueue_retry_receipt(data, key, job, reason, attempt, now=None):
"""Idempotently persist one mirrored progress receipt for attempts 1..5."""
attempt = int(attempt)
if attempt < 1 or attempt >= MAX_SIGNIN_ATTEMPTS:
return None
return enqueue(data, retry_receipt_key(key, job, attempt), "signin",
retry_receipt_text(job, reason, attempt), now)
def receipt_text(item, ok, message, payload=None, attempts=None, source="auto"):
item = item if isinstance(item, dict) else {}
payload = payload if isinstance(payload, dict) else {}
title = str(payload.get("title") or item.get("title") or "").strip()
lines = ["【白果云·%s签到】" % ("手动" if source == "manual" else "自动")]
if title:
lines.append("活动:%s" % title[:120])
lines.append("签到类型:%s" % signin_type_name(item, payload))
lines.append("结果:%s" % ("签到成功" if ok else "签到失败"))
reason = str(message or ("提交已确认成功" if ok else "未返回明确成功结果"))[:300]
# 签到成功时附上本次使用的签到码(普通签到无码则为空,不追加)
used_code = str(payload.get("signInCode") or "").strip()
if ok and used_code:
reason = "%s,用码 %s" % (reason, used_code)
lines.append("原因:%s" % reason)
if attempts is not None:
lines.append("尝试次数:%s/%s(首次 + 最多 5 次重试)" % (attempts, MAX_SIGNIN_ATTEMPTS))
lines.append("链接:%s" % str(item.get("link") or item.get("jump") or ""))
return "\n".join(lines)
def signin_receipt_key(key, job):
"""回执键:同一活动的每次重置(run_id)各一份,避免不同轮次互相覆盖。"""
suffix = (":" + str(job["run_id"])) if (isinstance(job, dict) and job.get("run_id")) else ""
return "signin:" + key + suffix
def finish_job(data, key, ok, reason, payload=None, status=None):
job = data.setdefault("signin_jobs", {}).get(key)
if not job or job.get("status") in ("succeeded", "failed", "cancelled"):
return
job.update(status=status or ("succeeded" if ok else "failed"),
reason=str(reason), finished_at=time.time(), next_attempt_at=None)
# Every reset gets a fresh receipt key; legacy jobs retain their historical key.
receipt_key = signin_receipt_key(key, job)
enqueue(data, receipt_key, "signin", receipt_text(
job.get("item"), ok, reason, payload, job.get("attempts", 0), signin_source(job)))
audit(data, "signin_finished", key, status=job["status"],
source=signin_source(job), run_id=job.get("run_id"),
attempts=job.get("attempts", 0), reason=str(reason))
def cancel_signins(data, reason, source=None):
"""Cancel all jobs, or just one source (used by auto-sign-in off)."""
for key, job in list(data.setdefault("signin_jobs", {}).items()):
if source is None or signin_source(job) == source:
finish_job(data, key, False, reason, status="cancelled")
def has_due(data, now=None):
now = time.time() if now is None else now
for entry in data.get("outbox", {}).values():
if entry.get("status") not in OUTBOX_TERMINAL and (entry.get("kind") != "notice" or data.get("notice_push")):
if (entry.get("next_attempt_at") or 0) <= now:
return True
# 限流冷却期内不下发签到任务(outbox 推送不受影响:那不请求白果云)。
if int(data.get("rate_limited_until") or 0) > now:
return False
for job in data.get("signin_jobs", {}).values():
if (job.get("status") in ("pending", "running") and signin_enabled(data, job)
and (job.get("next_attempt_at") or 0) <= now):
return True
return False
def discover(data, items, notice_text, now=None):
"""Seen ledger + all eligible outbox records + jobs in ONE caller transaction."""
now = time.time() if now is None else now
seen_list = list(dict.fromkeys(data.get("seen_keys") or []))
seen = set(seen_list)
for item in sorted(items, key=lambda row: row.get("ts") or 0):
key = str(item.get("key") or "")
if not key or key in seen:
continue
# 只给本轮确实被消费的消息记 seen:通知推送关时不消费通知、
# 自动签到关时不消费签到活动(与 keepalive 不写水位、commit 守卫
# "don't consume disabled arrivals" 同一约定);否则开关重新打开后,
# 关闭期间到达的消息会因已记 seen 而永远不补推。
consumed = False
if data.get("notice_push"):
enqueue(data, "notice:" + key, "notice", notice_text(item), now)
consumed = True
if (data.get("auto_sign_in") and item.get("kind") == "busi"
and "签到" in str(item.get("text") or "")
and item.get("signin_activity_key")):
enqueue_signin(data, item, "auto", now)
consumed = True
if not consumed:
continue
seen.add(key)
seen_list.append(key)
audit(data, "discovered", key)
data["seen_keys"] = seen_list
data["last_poll"] = now
def claim_outbox(data, key, claim_id, now):
row = data.get("outbox", {}).get(key)
if (not row or row.get("status") in OUTBOX_TERMINAL or (row.get("next_attempt_at") or 0) > now
or (row.get("kind") == "notice" and not data.get("notice_push"))):
return False
row.update(status="sending", claim_id=claim_id,
attempts=row.get("attempts", 0) + 1, last_attempt_at=now,
next_attempt_at=now + LEASE_SECONDS)
audit(data, "outbox_attempt", key, attempts=row["attempts"], claim_id=claim_id)
def ack_outbox(data, key, claim_id, acknowledged, reason="", now=None, succeeded=None):
"""确认一次 outbox 投递尝试。
succeeded: 本次**新确认成功**的目标 [(channel_name, target_id), ...](可选)。
累积进 row["sent_targets"],重试时据此跳过已成功目标,
绝不重复推送(避免 TG 重复轰炸)。
终态:
acknowledged is True -> sent(全部目标都已投递)
未确认 且 attempts >= 上限 -> failed(终态,停止重试)
未确认 且 未到上限 -> pending + backoff(保留该行继续重试)
"""
now = time.time() if now is None else now
row = data.get("outbox", {}).get(key)
if not row or row.get("claim_id") != claim_id or row.get("status") != "sending":
return False
if succeeded:
have = list(row.get("sent_targets") or [])
for name, target_id in succeeded:
token = target_key(name, target_id)
if token not in have:
have.append(token)
row["sent_targets"] = have
# bool True only; truthy strings/objects/integers are NOT channel acknowledgments.
if acknowledged is True:
row.update(status="sent", sent_at=now, next_attempt_at=None, last_error="")
else:
last_error = str(reason or "channel did not acknowledge True")
if row.get("attempts", 0) >= MAX_DELIVERY_ATTEMPTS:
row.update(status="failed", last_error=last_error, finished_at=now,
next_attempt_at=None)
else:
row.update(status="pending", last_error=last_error,
next_attempt_at=now + backoff(row["attempts"]))
if acknowledged is True:
event = "outbox_ack"
elif row.get("status") == "failed":
event = "outbox_failed"
else:
event = "outbox_retry"
audit(data, event, key, attempts=row["attempts"], reason=row.get("last_error", ""),
sent_targets=list(row.get("sent_targets") or []))
return True
def claim_signin(data, key, claim_id, now):
row = data.get("signin_jobs", {}).get(key)
if (not row or not signin_enabled(data, row)
or row.get("status") not in ("pending", "running")
or int(data.get("rate_limited_until") or 0) > now # 冷却期内不碰上游
or (row.get("next_attempt_at") or 0) > now):
return False
if row.get("attempts", 0) >= MAX_SIGNIN_ATTEMPTS:
finish_job(data, key, False, row.get("reason") or "最终尝试未得到确认(进程中断或超时)")
return
row.update(status="running", claim_id=claim_id,
attempts=row.get("attempts", 0) + 1, last_attempt_at=now,
next_attempt_at=now + LEASE_SECONDS)
audit(data, "signin_attempt", key, attempts=row["attempts"], claim_id=claim_id)
def abandon_signin_claim(data, key, claim_id):
"""A switch changed after reservation but before transport invocation."""
row = data.get("signin_jobs", {}).get(key)
if not row or row.get("claim_id") != claim_id:
return False
row["attempts"] = max(0, row.get("attempts", 0) - 1)
row.pop("claim_id", None)
if row.get("status") in ("pending", "running"):
if signin_source(row) == "auto":
finish_job(data, key, False, "自动签到已关闭,取消剩余重试", status="cancelled")
else:
row.update(status="pending", next_attempt_at=time.time())
audit(data, "signin_reservation_cancelled", key, attempts=row["attempts"],
source=signin_source(row))
def complete_signin(data, key, claim_id, message, payload, now=None):
now = time.time() if now is None else now
row = data.get("signin_jobs", {}).get(key)
if not row or row.get("claim_id") != claim_id:
return False
commit = payload.get("commit") if isinstance(payload, dict) else None
ok = isinstance(commit, dict) and commit.get("success") is True
if row.get("status") != "running":
# 竞态:网络请求在网期间用户关闭了自动签到 → 任务已被取消,但上游其实已经
# 提交成功。必须如实记为成功,并纠正已入队的"失败"回执(未投递则改写正文,
# 已投递则补发一条成功回执),否则用户会收到与事实相反的失败通知。
if row.get("status") != "cancelled" or not ok:
return False
text = receipt_text(row.get("item"), True, message, payload,
row.get("attempts", 0), signin_source(row))
stale = (data.get("outbox") or {}).get(signin_receipt_key(key, row))
if isinstance(stale, dict) and stale.get("status") not in OUTBOX_TERMINAL:
stale["text"] = text # 尚未投递:直接改正文案
elif isinstance(stale, dict):
enqueue(data, signin_receipt_key(key, row) + ":after-cancel", "signin", text)
row["status"] = "running" # 让 finish_job 接受这次真实成功
finish_job(data, key, True, message, payload)
audit(data, "signin_succeeded_after_cancel", key,
attempts=row.get("attempts", 0), reason=str(message))
return True
reason = str(message or ("签到成功" if ok else "未返回明确提交成功结果"))
if ok or row["attempts"] >= MAX_SIGNIN_ATTEMPTS:
finish_job(data, key, ok, reason, payload)
else:
row.update(status="pending", reason=reason,
next_attempt_at=now + SIGNIN_BACKOFF[row["attempts"] - 1])
enqueue_retry_receipt(data, key, row, reason, row["attempts"], now)
audit(data, "signin_retry", key, attempts=row["attempts"],
next_attempt_at=row["next_attempt_at"], reason=reason)
class AccountStore:
def __init__(self, directory):
self.directory = os.path.abspath(directory)
def path(self, account):
account = str(account)
if not account or not account.isdigit():
raise ValueError("account must contain digits only")
return os.path.join(self.directory, account + ".json")
@contextmanager
def locked(self, account):
path = self.path(account)
os.makedirs(self.directory, exist_ok=True)
with _LOCKS_GUARD:
lock = _LOCKS.setdefault(path, threading.RLock())
with lock:
with open(path + ".lock", "a+b") as handle:
if os.name == "nt":
import msvcrt
handle.seek(0, os.SEEK_END)
if handle.tell() == 0:
handle.write(b"\0")
handle.flush()
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
try:
yield path
finally:
if os.name == "nt":
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
def _read(self, path):
try:
with open(path, encoding="utf-8") as handle:
data = json.load(handle)
except FileNotFoundError:
return None
if not isinstance(data, dict):
raise ValueError("account state is not a JSON object")
return data
def _sync_directory(self):
if os.name != "nt":
fd = os.open(self.directory, os.O_RDONLY)
try:
os.fsync(fd)
finally:
os.close(fd)
def _write(self, path, data):
fd, tmp = tempfile.mkstemp(prefix=os.path.basename(path) + ".", suffix=".tmp", dir=self.directory)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(data, handle, ensure_ascii=False, indent=2)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp, path)
self._sync_directory()
finally:
if os.path.exists(tmp):
os.unlink(tmp)
def _normalize(self, path, data):
if data is not None and ("_generation" not in data or "_revision" not in data):
data.setdefault("_generation", uuid.uuid4().hex)
data.setdefault("_revision", 0)
self._write(path, data)
return data
def load(self, account):
with self.locked(account) as path:
return copy.deepcopy(self._normalize(path, self._read(path)))
def mutate(self, account, fn, generation=None, create=False):
"""Return committed fresh snapshot, or None for absent/wrong generation.
fn modifies the latest dict; returning False aborts. Creation is explicit
and must only be used by initial binding, never asynchronous completions.
"""
with self.locked(account) as path:
data = self._normalize(path, self._read(path))
if data is None:
if not create or generation is not None:
return None
data = {"account": str(account), "_generation": uuid.uuid4().hex, "_revision": 0}
if generation is not None and data.get("_generation") != generation:
return None
if fn(data) is False:
return None
prune_terminal(data)
data["account"] = str(account)
data["_revision"] += 1
data["updated_at"] = time.time()
self._write(path, data)
return copy.deepcopy(data)
def save(self, account, snapshot):
"""Compatibility CAS. Never implicitly creates/resurrects an account."""
def replace(data):
if (data.get("_generation"), data.get("_revision")) != (
snapshot.get("_generation"), snapshot.get("_revision")):
return False
data.clear()
data.update(copy.deepcopy(snapshot))
return self.mutate(account, replace, generation=snapshot.get("_generation"))
def delete(self, account, generation=None):
with self.locked(account) as path:
data = self._normalize(path, self._read(path))
if data is None or (generation is not None and data.get("_generation") != generation):
return False
os.unlink(path)
self._sync_directory()
return True
# ==================== 签到 URL 解析 / 签到接口(原 ginkgostu_signin.py 独有部分) ====================
# 来源:hk 主机 /home/openapi/source/dufe/ginkgostu_sign_in/ginkgo_sign_in.py
# (2026-09-14 拉取,md5 92a6753fe39cd025a954bc8e6553f29a)
# AES / SSO 登录 / 会话部分与上方轮询登录共用一份实现,不再重复。
class NoSignInCodeError(Exception):
"""The numeric-code activity exists but has no code configured."""
class UnsupportedSigninType(Exception):
"""The activity requires a flow this module cannot safely automate."""
SIGNIN_TYPE_NAMES = {
0: "二维码签到",
1: "数字码签到",
2: "普通签到",
3: "直播签到",
}
def signin_type_label(value):
try:
signin_type = int(value)
except (TypeError, ValueError):
return "未知签到类型"
return SIGNIN_TYPE_NAMES.get(signin_type, "未知签到类型(%s)" % signin_type)
def parse_course_url(url):
parts = urlsplit(str(url or ""))
m = re.fullmatch(
r"/course/detail/(?P<courseId>[0-9A-Za-z]+)/(?P<batchNo>[^/]+)/"
r"(?P<weekTimeId>\d+)/signin/(?P<signInId>\d+)/(?P<type>\d+)/?",
parts.path)
try:
port = parts.port
except ValueError:
port = -1
if (parts.scheme != "https" or parts.username is not None or parts.password is not None
or port not in (None, 443)
or (parts.hostname or "").lower() != "ginkgostu.dufe.edu.cn" or not m):
raise ValueError(
"URL is not a sign-in page (missing /signin/<signInId>/<type>)")
return m.groupdict()
def _request(sess, method, *args, **kwargs):
"""Also guard fake sessions and HTTPError.response before any JSON parsing."""
try:
response = getattr(sess, method)(*args, **kwargs)
except requests.RequestException as exc:
raise UpstreamError(safe_error(exc)) from None
return check_response(response, "signin")
def api_headers(token, fingerprint=None):
return {
"User-Agent": UA,
"Origin": GINKGO,
"Referer": GINKGO + "/",
"Content-Type": "application/x-www-form-urlencoded",
"token": token,
"fingerprint": fingerprint or uuid.uuid4().hex,
}
def call_api(sess, path, data, token):
return _request(sess, "post", GINKGO + path, data=data, timeout=20,
headers=api_headers(token))
def commit_sign_in(sess, token, sign_in_id, batch_no, referer, code):
"""
POST /student/signIn/InTheSign to actually check in with the given code.
Mirrors the browser request:
headers: batchno, fingerprint, token, Referer (course page)
body: signInId=<id>&code=<code>
Returns (ok: bool, message: str, raw: dict|None).
"""
headers = api_headers(token)
if batch_no:
# NOTE: the browser sends a "batchno" header that does not always equal
# the batchNo segment of the course URL. We pass the URL segment here;
# the server appears not to enforce it.
headers["batchno"] = str(batch_no)
headers["Referer"] = referer
try:
r = _request(sess, "post",
GINKGO + "/student/signIn/InTheSign",
data={"signInId": str(sign_in_id), "code": str(code)},
timeout=20,
headers=headers,
)
except (requests.RequestException, UpstreamError) as e:
return False, "request to InTheSign failed: %s" % safe_error(e), None
if r.status_code != 200:
return False, "InTheSign returned HTTP %d" % r.status_code, None
try:
j = r.json()
except ValueError:
return False, "InTheSign returned non-JSON", None
if not isinstance(j, dict):
return False, "Malformed InTheSign response", None
api_code = str(j.get("code", ""))
if api_code == "2000":
return True, "accepted", j
# Do not echo untrusted server messages/codes: they may contain credentials.
return False, "InTheSign refused the sign-in request", j
def fetch_sign_in_info(username, password, course_url, do_commit=True, sess=None, token=None,
avoid_duplicate=False):
route = parse_course_url(course_url)
sign_in_id = route["signInId"]
batch_no = route.get("batchNo")
owns_session = sess is None or not token
try:
if owns_session:
# 无外部登录态时自登录(保持向后兼容);本函数负责关闭自建会话。
sess, token, _student_name = login(username, password)
# 否则:sess/token 由调用方(轮询缓存会话)提供,跳过 SSO 登录
r = call_api(sess, "/student/signIn/getSignInInfo",
{"signInId": sign_in_id}, token)
check_response(r, "getSignInInfo")
try:
j = r.json()
except ValueError:
raise UpstreamError("getSignInInfo returned non-JSON") from None
if not isinstance(j, dict):
raise UpstreamError("Malformed getSignInInfo response")
data = j.get("data") or {}
if not isinstance(data, dict):
raise UpstreamError("Malformed getSignInInfo data")
model = data.get("signInModel") or {}
if not isinstance(model, dict):
raise UpstreamError("Malformed getSignInInfo model")
code = model.get("code")
raw_type = model.get("type")
if raw_type is None or raw_type == "":
raw_type = route.get("type")
try:
signin_type = int(raw_type)
except (TypeError, ValueError):
raise UpstreamError("Malformed sign-in type") from None
type_name = signin_type_label(signin_type)
if signin_type == 1 and (code is None or code == ""):
raise NoSignInCodeError("数字码签到未配置签到码")
if signin_type == 2:
code = ""
result = {
"signInId": sign_in_id,
"signInCode": code,
"type": signin_type,
"typeName": type_name,
"title": model.get("title"),
"status": data.get("signStatus"),
}
if do_commit:
if avoid_duplicate and str(result["status"]) == "1":
# A prior attempt may have committed before its response was lost.
# Confirm the current state instead of blindly posting InTheSign again.
result["commit"] = {"success": True, "message": "已确认此前签到成功"}
elif signin_type not in (1, 2):
result["commit"] = {
"success": False,
"message": "%s暂不支持自动签到,需特定流程" % type_name,
}
else:
ok, reason, _raw = commit_sign_in(
sess, token, sign_in_id, batch_no, course_url, code
)
result["commit"] = {"success": ok, "message": reason}
return result
finally:
if owns_session and sess is not None:
try:
sess.close()
except Exception:
pass
# ==================== 常量 ====================
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
STATE_DIR = os.path.join(BASE_DIR, "ginkgostu")
GINKGO = "https://ginkgostu.dufe.edu.cn"
SSO = "https://sso.dufe.edu.cn"
NOTICE_PAGE = GINKGO + "/notice"
SYSTEM_PAGE = GINKGO + "/notice/system"
# SSO 登录页固定 CSRF 常量(同时作为密码加密的 key/iv 来源)
TOKEN_CONST = "616246bd4a7266f46764686240a106f0"
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36")
HTTP_TIMEOUT = 25
PAGE_SIZE = 20 # 单类单次抓取条数
LIST_MAX = 20 # .ginkgo notice <int> 上限
PUSH_MAX = 5 # 单轮最多逐条推送的新消息数
# 去重账本 seen_keys 有意保持"精确且不截断":它是唯一能阻止旧消息被重新当成
# 新消息推送的凭据(通知接口只回最新 20 条,实际增长很慢)。
ACCOUNT_CACHE_SECONDS = 5
ACCOUNT_CONCURRENCY = 5 # 并发预备:同时轮询的账号数上限
RATE_LIMIT_COOLDOWN = 1800 # 触发限流后的建议冷却时间(秒)
# 限流判定统一走 is_rate_limited()(自带错误码与文案集合),此处不再重复定义。
TZ8 = timezone(timedelta(hours=8))
_requests_log_silenced = False
# ==================== 纯 Python AES-128(取自 openapi ginkgostu_sign_in 服务,验证过) ====================
_SBOX = [
0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76,
0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0,
0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15,
0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75,
0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84,
0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf,
0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8,
0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2,
0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73,
0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb,
0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79,
0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08,
0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a,
0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e,
0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf,
0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16,
]
_INV_SBOX = [0] * 256
for _i, _v in enumerate(_SBOX):
_INV_SBOX[_v] = _i
_RCON = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36]
def _gmul(a, b):
p = 0
for _ in range(8):
if b & 1:
p ^= a
hi = a & 0x80
a = (a << 1) & 0xFF
if hi:
a ^= 0x1B
b >>= 1
return p
def _expand_key(key):
w = [list(key[4 * i:4 * i + 4]) for i in range(4)]
for i in range(4, 44):
t = list(w[i - 1])
if i % 4 == 0:
t = t[1:] + t[:1]
t = [_SBOX[b] for b in t]
t[0] ^= _RCON[i // 4 - 1]
w.append([w[i - 4][j] ^ t[j] for j in range(4)])
return w
def _add_round_key(s, w, rnd):
for c in range(4):
for r in range(4):
s[r][c] ^= w[rnd * 4 + c][r]
def _sub_bytes(s, box):
for r in range(4):
for c in range(4):
s[r][c] = box[s[r][c]]
def _shift_rows(s):
for r in range(1, 4):
s[r] = s[r][r:] + s[r][:r]
def _inv_shift_rows(s):
for r in range(1, 4):
s[r] = s[r][-r:] + s[r][:-r]
def _mix_columns(s):
for c in range(4):
a = [s[r][c] for r in range(4)]
s[0][c] = _gmul(a[0], 2) ^ _gmul(a[1], 3) ^ a[2] ^ a[3]
s[1][c] = a[0] ^ _gmul(a[1], 2) ^ _gmul(a[2], 3) ^ a[3]
s[2][c] = a[0] ^ a[1] ^ _gmul(a[2], 2) ^ _gmul(a[3], 3)
s[3][c] = _gmul(a[0], 3) ^ a[1] ^ a[2] ^ _gmul(a[3], 2)
def _inv_mix_columns(s):
for c in range(4):
a = [s[r][c] for r in range(4)]
s[0][c] = _gmul(a[0], 14) ^ _gmul(a[1], 11) ^ _gmul(a[2], 13) ^ _gmul(a[3], 9)
s[1][c] = _gmul(a[0], 9) ^ _gmul(a[1], 14) ^ _gmul(a[2], 11) ^ _gmul(a[3], 13)
s[2][c] = _gmul(a[0], 13) ^ _gmul(a[1], 9) ^ _gmul(a[2], 14) ^ _gmul(a[3], 11)
s[3][c] = _gmul(a[0], 11) ^ _gmul(a[1], 13) ^ _gmul(a[2], 9) ^ _gmul(a[3], 14)
class _AES128:
def __init__(self, key):
if len(key) != 16:
raise ValueError("AES-128 requires a 16-byte key, got %d" % len(key))
self.w = _expand_key(key)
def encrypt_block(self, block):
s = [[block[r + 4 * c] for c in range(4)] for r in range(4)]
_add_round_key(s, self.w, 0)
for rnd in range(1, 10):
_sub_bytes(s, _SBOX)
_shift_rows(s)
_mix_columns(s)
_add_round_key(s, self.w, rnd)
_sub_bytes(s, _SBOX)
_shift_rows(s)
_add_round_key(s, self.w, 10)
return bytes(s[r][c] for c in range(4) for r in range(4))
def decrypt_block(self, block):
s = [[block[r + 4 * c] for c in range(4)] for r in range(4)]
_add_round_key(s, self.w, 10)
for rnd in range(9, 0, -1):
_inv_shift_rows(s)
_sub_bytes(s, _INV_SBOX)
_add_round_key(s, self.w, rnd)
_inv_mix_columns(s)
_inv_shift_rows(s)
_sub_bytes(s, _INV_SBOX)
_add_round_key(s, self.w, 0)
return bytes(s[r][c] for c in range(4) for r in range(4))
def _pkcs7_unpad(data):
if not data:
return data
n = data[-1]
if 1 <= n <= 16 and data[-n:] == bytes([n]) * n:
return data[:-n]
return data
def _zero_pad(data):
if len(data) % 16:
data += b"\x00" * (16 - len(data) % 16)
return data
def aes_ecb_decrypt_b64(cipher_b64, key_text):
"""滑块答案:密文 base64 + 以 token 前 16 字节为密钥的 AES-128-ECB/PKCS7"""
aes = _AES128(key_text[:16].encode("utf-8"))
raw = base64.b64decode(cipher_b64)
out = b"".join(aes.decrypt_block(raw[i:i + 16]) for i in range(0, len(raw), 16))
return _pkcs7_unpad(out)
def aes_cbc_encrypt_b64_zero(plain, key_text):
"""登录表单密码:AES-128-CBC + ZeroPadding,key=iv=__token__ 前 16 字节"""
key = key_text[:16].encode("utf-8")
aes = _AES128(key)
data = _zero_pad(plain.encode("utf-8"))
out, prev = b"", key
for i in range(0, len(data), 16):
blk = bytes(x ^ y for x, y in zip(data[i:i + 16], prev))
prev = aes.encrypt_block(blk)
out += prev
return base64.b64encode(out).decode("ascii")
# ==================== 文本 / 时间工具 ====================
_TAG_RE = re.compile(r"<[^>]+>")
def _html_to_text(raw):
if not raw:
return ""
text = re.sub(r"(?i)<br\s*/?>", "\n", raw)
text = re.sub(r"(?i)</(p|div|tr|li)>", "\n", text)
text = _TAG_RE.sub("", text)
text = _html.unescape(text)
text = re.sub(r"[ \t\u3000]+", " ", text)
text = re.sub(r"\n{2,}", "\n", text)
return text.strip()
def _clip(text, limit):
text = (text or "").strip()
if len(text) <= limit:
return text
return text[:limit - 1] + "…"
def _now_ts():
return time.time()
def _fmt_ts(ts):
if not ts:
return ""
return datetime.fromtimestamp(ts, TZ8).strftime("%Y-%m-%d %H:%M:%S")
def _parse_ts(value):
text = (value or "").strip()
if not text:
return 0.0
if text.endswith("Z"):
text = text[:-1] + "+00:00"
m = re.search(r"([+-]\d{2})(\d{2})$", text)
if m:
text = text[:m.start()] + m.group(1) + ":" + m.group(2)
try:
dt = datetime.fromisoformat(text)
except ValueError:
try:
dt = datetime.strptime(text[:19], "%Y-%m-%d %H:%M:%S")
except ValueError:
return 0.0
if dt.tzinfo is None:
dt = dt.replace(tzinfo=TZ8)
return dt.timestamp()
def _stamp():
return datetime.now(TZ8).strftime("%Y-%m-%d %H:%M:%S")
# ==================== 异常 ====================
class AuthError(Exception):
"""账号密码错误 / 登录被拒"""
class UpstreamError(Exception):
"""远端接口异常或返回结构变化"""
# ==================== 状态文件 ====================
def _ensure_dir():
os.makedirs(STATE_DIR, exist_ok=True)
def _account_file(account):
return os.path.join(STATE_DIR, "%s.json" % account)
def _store():
# Construct on demand so deployments/tests may relocate STATE_DIR safely.
return AccountStore(STATE_DIR)
def _load_account(account):
return _store().load(account)
def _mutate_account(account, fn, generation=None, create=False):
def update(data):
result = fn(data)
if result is not False:
data["updated"] = _stamp()
return result
result = _store().mutate(account, update, generation=generation, create=create)
_state_cache["ts"] = 0.0
return result
def _save_account(account, data):
"""Legacy snapshot API: stale snapshots fail closed; creation is bind-only."""
result = _store().save(account, data)
_state_cache["ts"] = 0.0
return result
def _delete_account(account, generation=None):
result = _store().delete(account, generation)
_state_cache["ts"] = 0.0
return result
_state_cache = {"ts": 0.0, "data": []}
_state_lock = threading.Lock()
def list_accounts(force=False):
"""列出全部已绑定账号(5 秒缓存,轮询循环每秒调用)"""
now = time.time()
with _state_lock:
if not force and now - _state_cache["ts"] < ACCOUNT_CACHE_SECONDS:
return _state_cache["data"]
result = []
try:
names = [n for n in os.listdir(STATE_DIR) if n.endswith(".json")]
except OSError:
names = []
for name in names:
if not name[:-5].isdigit():
continue
try:
data = _load_account(name[:-5])
except (OSError, ValueError):
# Corrupt/unreadable files are never treated as fresh accounts or
# overwritten; isolate one bad account from the rest of the monitor.
print("[ginkgo] 跳过无法读取的账号状态 %s" % name)
continue
if data:
result.append(data)
with _state_lock:
_state_cache["ts"] = now
_state_cache["data"] = result
return result
def find_by_uid(uid):
"""统一身份:一个 uid 只允许绑定一个白果云账号"""
for data in list_accounts(force=True):
if str(data.get("user_id")) == str(uid):
return data
return None
def find_by_account(account):
try:
return _load_account(account)
except (OSError, ValueError):
# 与 list_accounts 同一约定:损坏/无法读取的状态文件按「未绑定」处理并打日志,
# 绝不让 JSONDecodeError 逃出指令层(后续写入路径仍有自己的防护)。
print("[ginkgo] 无法读取账号状态 %s,按未绑定处理" % account)
return None
# ==================== 白果云协议层(同步,供 asyncio.to_thread 调用) ====================
def new_session():
s = requests.Session()
s.trust_env = False
s.headers.update({"User-Agent": UA, "Accept-Language": "zh-CN,zh;q=0.9"})
s.hooks["response"] = [lambda response, *args, **kwargs: check_response(response)]
return s
def get_unify_config(sess):
r = sess.get(GINKGO + "/student/getLoginUnifySet", timeout=HTTP_TIMEOUT)
_raise_if_rate_limited(r, "白果云 getLoginUnifySet")
r.raise_for_status()
try:
cfg = r.json()["data"]
except (ValueError, KeyError, TypeError) as e:
raise UpstreamError("getLoginUnifySet 返回异常: %s" % e)
if not isinstance(cfg, dict):
raise UpstreamError("getLoginUnifySet data 结构异常")
if not cfg.get("loginUrl") or not cfg.get("clientId"):
raise UpstreamError("getLoginUnifySet 缺少 loginUrl/clientId")
return cfg
def open_authorize(sess, cfg):
params = {
"response_type": cfg.get("responseType") or "code",
"client_id": cfg["clientId"],
"redirect_uri": cfg.get("rollBackUrl") or (GINKGO + "/login"),
}
r = sess.get(cfg["loginUrl"], params=params, timeout=HTTP_TIMEOUT, allow_redirects=True)
_raise_if_rate_limited(r, "SSO 授权页")
r.raise_for_status()
m = re.search(r'name="__token__"\s+value="([^"]+)"', r.text)
if not m:
raise UpstreamError("SSO 登录页未找到 __token__")
return (m.group(1) or TOKEN_CONST), r.url
def exploit_slider(sess, referer):
"""滑块验证码:服务端直接下发答案(data.d 用 data.token 前 16 字节 EC B 解密即 x)"""
url = (SSO + "/auth/widget?layer=image_verify&widget=Slider"
"&action=get_verify&type=login_image_verify")
r = sess.post(url, data={"width": 280, "height": 155, "block_size": 80},
headers={"X-Requested-With": "XMLHttpRequest", "Referer": referer},
timeout=HTTP_TIMEOUT)
_raise_if_rate_limited(r, "SSO 滑块验证码")
r.raise_for_status()
try:
d = r.json()["data"]
token, cipher = d["token"], d["d"]
except (ValueError, KeyError, TypeError) as e:
raise UpstreamError("滑块挑战返回异常: %s" % e)
x = aes_ecb_decrypt_b64(cipher, token).decode("utf-8", "replace")
return {"token": token, "x": x}
_ERR_HINTS = ("密码", "账号", "用户", "锁定", "失效", "验证", "错误", "不存在",
"频繁", "限流", "限速", "稍后")
def _extract_login_error(page_text):
"""从 SSO 失败页里提取提示文案(登录失败时返回 200 + 错误信息)"""
for pat in (r'id="error_container"[^>]*>(.*?)</div>',
r'class="[^"]*(?:error|tip)[^"]*"[^>]*>(.*?)</div>'):
m = re.search(pat, page_text or "", re.S)
if m:
txt = _html_to_text(m.group(1))
if txt and any(h in txt for h in _ERR_HINTS):
return _clip(txt, 120)
return ""
def submit_login(sess, post_url, csrf, username, password, slider, referer):
form = {
"fingerprint": _uuid.uuid4().hex,
"username": username,
"password": aes_cbc_encrypt_b64_zero(password, csrf),
"verify_token": slider["token"],
"verify_code": slider["x"],
"__token__": csrf,
}
return sess.post(post_url, data=form, timeout=HTTP_TIMEOUT, allow_redirects=False,
headers={"Referer": referer, "Origin": SSO,
"Content-Type": "application/x-www-form-urlencoded"})
def exchange_token(sess, code):
r = sess.post(GINKGO + "/student/authLogin", data={"code": code}, timeout=HTTP_TIMEOUT,
headers={"Content-Type": "application/x-www-form-urlencoded"})
_raise_if_rate_limited(r, "authLogin")
try:
j = r.json()
except ValueError as e:
raise UpstreamError("authLogin 返回非 JSON: %s" % e)
if not isinstance(j, dict):
raise UpstreamError("authLogin 返回结构异常")
if str(j.get("code")) != "2000":
if _looks_rate_limited(None, j):
raise RateLimitedError("authLogin 被限流: %s" % json.dumps(j, ensure_ascii=False)[:200])
raise AuthError("换取登录态被拒: %s" % json.dumps(j, ensure_ascii=False)[:200])
data = j.get("data")
if not isinstance(data, dict):
raise UpstreamError("authLogin data 结构异常")
return data
def login(account, password):
"""完整走一遍 SSO 流程,返回 (session, token, student_name)。
成功时会话所有权转交调用方;任何失败都必须关闭已创建的 SSO 会话。
"""
sess = new_session()
try:
cfg = get_unify_config(sess)
csrf, page_url = open_authorize(sess, cfg)
slider = exploit_slider(sess, page_url)
resp = submit_login(sess, page_url, csrf, account, password, slider, page_url)
check_response(resp, "SSO 登录")
loc = resp.headers.get("Location", "") or ""
m = re.search(r"[?&]code=([A-Za-z0-9_\-]+)", loc)
if not m:
if resp.status_code == 200:
hint = _extract_login_error(resp.text)
else:
hint = ""
if _looks_rate_limited(None, None, hint):
raise RateLimitedError("SSO 登录被限流:%s" % (hint or "访问过于频繁"))
raise AuthError(hint or "账号或密码错误(登录未返回授权码)")
data = exchange_token(sess, m.group(1))
token = data.get("token")
if not token:
raise UpstreamError("authLogin 未返回 token")
return sess, token, (data.get("studentName") or "").strip()
except BaseException:
try:
sess.close()
except Exception:
pass
raise
class GinkgoSession:
"""单账号登录态(含失效自动重登)"""
def __init__(self, account, password):
self.account = account
self.password = password
self.req = None
self.token = None
self.student_name = ""
self.lock = threading.Lock()
def _login_locked(self):
old_req = self.req
req, token, student_name = login(self.account, self.password)
self.req, self.token, self.student_name = req, token, student_name
if old_req is not None and old_req is not req:
try:
old_req.close()
except Exception:
pass
def ensure(self):
with self.lock:
if self.req is None or not self.token:
self._login_locked()
return self.req, self.token
def refresh(self):
with self.lock:
self._login_locked()
return self.req, self.token
_sessions = {}
_sessions_lock = threading.Lock()
def get_session(account, password):
with _sessions_lock:
sess = _sessions.get(account)
if sess is None or sess.password != password:
if sess is not None and sess.req is not None:
try:
sess.req.close()
except Exception:
pass
sess = GinkgoSession(account, password)
_sessions[account] = sess
return sess
def _api_headers(token):
return {
"User-Agent": UA,
"Origin": GINKGO,
"Referer": GINKGO + "/",
"Content-Type": "application/x-www-form-urlencoded",
"token": token,
"fingerprint": _uuid.uuid4().hex,
}
def _looks_rate_limited(status=None, payload=None, text=""):
return is_rate_limited(status, payload, text)
def _raise_if_rate_limited(r, what):
return check_response(r, what)
def _safe_json(r, what):
check_response(r, what)
try:
payload = r.json()
except ValueError:
raise UpstreamError("%s 返回非 JSON(HTTP %s)" % (what, r.status_code))
if not isinstance(payload, dict):
raise UpstreamError("%s JSON 必须为对象" % what)
return payload
def _need_relogin(r, payload):
if r.status_code in (401, 403):
return True
if isinstance(payload, dict) and str(payload.get("code")) in ("4000", "4001", "4010"):
return True
return False
def _api_request(sess, method, path, params, body):
req, token = sess.ensure()
headers = _api_headers(token)
if method == "GET":
return req.get(GINKGO + path, params=params, headers=headers, timeout=HTTP_TIMEOUT)
headers["Content-Type"] = "application/json"
return req.post(GINKGO + path, data=json.dumps(body if body is not None else {}),
headers=headers, timeout=HTTP_TIMEOUT)
def _check_limited(r, what):
return check_response(r, what)
def _call(sess, method, path, params=None, body=None, what="接口"):
r = _api_request(sess, method, path, params, body)
_check_limited(r, what)
payload = _safe_json(r, what)
if _looks_rate_limited(None, payload):
raise RateLimitedError("%s 触发频率限制:%s"
% (what, payload.get("message") or payload.get("code")))
if _need_relogin(r, payload):
sess.refresh()
r = _api_request(sess, method, path, params, body)
_check_limited(r, what)
payload = _safe_json(r, what)
if _looks_rate_limited(None, payload):
raise RateLimitedError("%s 触发频率限制:%s"
% (what, payload.get("message") or payload.get("code")))
if str(payload.get("code")) != "2000":
raise UpstreamError("%s 返回 code=%s message=%s"
% (what, payload.get("code"), payload.get("message")))
return payload
def _normalize_notice(raw):
title = (raw.get("title") or "").strip() or "(无标题)"
ts = _parse_ts(raw.get("createTime"))
return {
"kind": "notice",
"kinder": "通知",
"key": "N%s" % raw.get("id"),
"id": str(raw.get("id") or ""),
"title": title,
"text": _html_to_text(raw.get("content") or ""),
"ts": ts,
"time": _fmt_ts(ts),
"link": NOTICE_PAGE,
"sender": (raw.get("createUser") or "").strip(),
"jump": "",
}
def _normalize_busi(raw):
text = _html_to_text(raw.get("content") or "")
jump = (raw.get("jumpUrl") or "").strip()
if jump.startswith("http"):
link = jump
elif jump.startswith("/"):
link = GINKGO + jump
else:
link = SYSTEM_PAGE
ts = _parse_ts(raw.get("createTime"))
return {
"kind": "busi",
"kinder": "互动消息",
"key": "B%s" % (raw.get("uuid") or ""),
"uuid": str(raw.get("uuid") or ""),
"id": "",
"title": _clip(text, 120) or "(无内容)",
"text": text,
"ts": ts,
"time": _fmt_ts(ts),
"link": link,
"sender": "",
"jump": jump,
}
def fetch_all(sess, page_size=PAGE_SIZE):
"""取两类消息(均为全部,含已读;去重由调用方按 key 处理)"""
j1 = _call(sess, "GET", "/student/message/getMessageAll",
params={"pageSize": page_size, "pageNo": 1, "readFlag": 0}, what="通知列表")
notices = [_normalize_notice(x) for x in ((j1.get("data") or {}).get("list") or [])]
j2 = _call(sess, "POST", "/student/busiMessage/list", body={}, what="互动消息列表")
busi = [_normalize_busi(x) for x in (j2.get("data") or [])]
return notices, busi
def _parse_signin_route(link):
return parse_course_url(link)
def _signin_type_label(value):
return signin_type_label(value)
def _validated_signin_item(item):
"""Return an annotated copy only for a real interactive sign-in activity."""
if (not isinstance(item, dict) or item.get("kind") != "busi"
or "签到" not in str(item.get("text") or "")):
return None
link = str(item.get("link") or "").strip()
try:
route = _parse_signin_route(link)
except (ValueError, TypeError, AttributeError):
return None
row = dict(item)
# Persist/display only the canonical path: query parameters may carry tokens
# and must never reach receipts or push logs.
row["link"] = GINKGO + urlsplit(link).path
# The message UUID remains the notice watermark; signInId is the activity
# identity, preventing different messages for one activity from overlapping.
row["signin_activity_key"] = "S%s" % route["signInId"]
try:
row["signin_type"] = int(route["type"])
except (TypeError, ValueError, KeyError):
return None
row["signin_type_name"] = _signin_type_label(row["signin_type"])
return row
def _latest_signin_activity(items):
valid = [row for row in (_validated_signin_item(item) for item in (items or [])) if row]
return max(valid, key=lambda row: row.get("ts") or 0) if valid else None
def _annotate_signin_items(items):
"""Annotate valid jobs for automatic discovery; leave all notices intact."""
result = []
for item in items:
result.append(_validated_signin_item(item) or item)
return result
def _normalize_signin_payload(data):
if isinstance(data, dict) and data.get("type") is not None:
data = dict(data)
try:
data["type"] = int(data["type"])
except (TypeError, ValueError):
pass
data["typeName"] = _signin_type_label(data.get("type"))
return data
def _signin_result_message(data, ok):
if ok:
return "签到成功"
if isinstance(data, dict):
try:
signin_type = int(data.get("type"))
except (TypeError, ValueError):
signin_type = None
if signin_type not in (None, 1, 2):
return "%s暂不支持自动签到,需特定流程" % _signin_type_label(signin_type)
return "签到失败(上游未确认提交成功)"
def _call_sign_in_local(account, password, url, check_status=False):
"""进程内本地签到:复用轮询缓存登录态(不再每次完整 SSO 登录)。
轮询 token 失效时 refresh 会话重试一次;仍失败按原语义返回失败。
重试提交时 check_status=True,先以 getSignInInfo 确认未签到,避免重复提交。"""
try:
gs = get_session(account, password)
req, token = gs.ensure()
try:
data = fetch_sign_in_info(
account, password, url, do_commit=True, sess=req, token=token,
avoid_duplicate=check_status)
except (UpstreamError, AuthError):
# 缓存 token 失效:强制 refresh 轮询会话并重试一次
req, token = gs.refresh()
data = fetch_sign_in_info(
account, password, url, do_commit=True, sess=req, token=token,
avoid_duplicate=check_status)
except RateLimitedError as e:
return False, str(e), None, True
except NoSignInCodeError:
return False, "数字码签到未配置签到码", None, False
except UnsupportedSigninType as e:
# This text is locally authored from the normalized type, not upstream data.
return False, str(e), None, False
except Exception as e:
try:
reason = safe_error(e)
except RateLimitedError as limited:
return False, str(limited), None, True
return False, reason, None, _looks_rate_limited(None, None, reason)
if not isinstance(data, dict):
return False, "签到模块返回非对象 JSON", None, False
data = _normalize_signin_payload(data)
commit = data.get("commit")
if not isinstance(commit, dict):
return False, "签到失败(缺少明确提交结果)", data, False
limited = _looks_rate_limited(None, commit)
if limited:
return False, "签到请求触发上游频率限制", data, True
ok = commit.get("success") is True
return ok, _signin_result_message(data, ok), data, False
def call_sign_in_api(account, password, url, check_status=False):
"""自动签到统一入口(进程内本地 SSO + 签到)。
返回 (ok, message, data, rate_limited)
"""
return _call_sign_in_local(account, password, url, check_status=check_status)
# ==================== 轮询规则 ====================
RULE_ROWS = (
("每日 00:00-05:00", 3600, "1 小时"),
("每日 05:00-07:00", 300, "5 分钟"),
("每日 07:00-11:30", 10, "10 秒"),
("每日 11:30-12:30", 300, "5 分钟"),
("每日 12:30-15:30", 10, "10 秒"),
("每日 15:30-18:00", 300, "5 分钟"),
("每日 18:00-21:00", 10, "10 秒"),
("每日 21:00-24:00", 300, "5 分钟"),
)
def current_interval(now=None):
"""返回 (间隔秒数, 档位说明);时间为 UTC+8;全周统一(周末不再单独降频)"""
now = now or datetime.now(TZ8)
t = now.time()
if t < dtime(5, 0):
return 3600, "00:00-05:00 → 1 小时一次"
if t < dtime(7, 0):
return 300, "05:00-07:00 → 5 分钟一次"
if t < dtime(11, 30):
return 10, "07:00-11:30 → 10 秒一次"
if t < dtime(12, 30):
return 300, "11:30-12:30 → 5 分钟一次"
if t < dtime(15, 30):
return 10, "12:30-15:30 → 10 秒一次"
if t < dtime(18, 0):
return 300, "15:30-18:00 → 5 分钟一次"
if t < dtime(21, 0):
return 10, "18:00-21:00 → 10 秒一次"
return 300, "21:00-24:00 → 5 分钟一次"
# 保活:只对「通知推送和自动签到都关」的账号生效——这类账号完全不轮询,
# 登录态长期不用会失效;每天 11:55 与 23:55 各探活一次,走
# fetch_all → _call 的 _need_relogin → sess.refresh() 路径刷新登录态。
# 每个时段带 KEEPALIVE_GRACE 秒宽限:进程稍晚启动(如 11:55:30 重启)也能补触发,
# 已触发过的时段不重复。处于限流冷却期(rate_limited_until 未到)的账号跳过。
KEEPALIVE_TIMES = ((11, 55), (23, 55))
KEEPALIVE_GRACE = 600
def _next_keepalive(now_ts):
"""下一次保活时段的时间戳(严格未来),供状态展示。"""
now = datetime.fromtimestamp(now_ts, TZ8)
candidates = []
for day_offset in (0, 1):
day = now + timedelta(days=day_offset)
for hour, minute in KEEPALIVE_TIMES:
target = day.replace(hour=hour, minute=minute, second=0, microsecond=0).timestamp()
if target > now_ts:
candidates.append(target)
return min(candidates)
def _due_keepalive(now_ts, last_fired=0):
"""返回此刻应当触发的保活时段戳(无则 None)。
时段到点即触发;时段过后 KEEPALIVE_GRACE 秒内仍可补触发(跨重启兜底);
已经触发过的时段(last_fired)不再重复触发。
"""
now = datetime.fromtimestamp(now_ts, TZ8)
due = []
for day_offset in (0, -1):
day = now + timedelta(days=day_offset)
for hour, minute in KEEPALIVE_TIMES:
target = day.replace(hour=hour, minute=minute, second=0, microsecond=0).timestamp()
if target <= now_ts < target + KEEPALIVE_GRACE and target > (last_fired or 0):
due.append(target)
return min(due) if due else None
def rule_text():
_, label = current_interval()
lines = ["白果云轮询规则(UTC+8)", "━━━━━━━━━━━━"]
for name, _sec, human in RULE_ROWS:
lines.append("• %s %s" % (name, human))
lines.append("━━━━━━━━━━━━")
lines.append("当前档位:%s" % label)
lines.append("轮询在「通知推送」或「自动签到」开启时运行;两者都关则停止轮询")
lines.append("仅开自动签到(通知推送关闭)时,只在 10 秒档时段轮询,其余时段停止")
lines.append("通知与签到都关的账号:每天 11:55 / 23:55 各保活探活一次(仅刷新登录态)")
return "\n".join(lines)
HELP_TEXT = (
"白果云(东北财经大学)服务\n"
"━━━━━━━━━━━━\n"
"• .ginkgo help 显示本帮助\n"
"• .ginkgo rule 查看轮询规则\n"
"• .ginkgo bind <账号> <密码> 绑定账号(返回学生姓名)\n"
"• .ginkgo unbind [账号] 解绑账号\n"
"• .ginkgo status 查看绑定与开关状态\n"
"• .ginkgo sign 强制执行最新有效签到活动\n"
"• .ginkgo notice <N> 查看最新 N 条通知(通知+互动消息)\n"
"• .ginkgo notice <on|off> 开关通知推送\n"
"• .ginkgo auto-sign-in <on|off> 开关自动签到\n"
"━━━━━━━━━━━━\n"
"触发频率限制时会自动关闭推送与签到并通知你\n"
"每人仅可绑定一个账号"
)
# ==================== 命令层(异步) ====================
_async_states = weakref.WeakKeyDictionary()
def _async_state():
loop = asyncio.get_running_loop()
if loop not in _async_states:
_async_states[loop] = {"accounts": {}, "users": {},
"sem": asyncio.Semaphore(ACCOUNT_CONCURRENCY)}
return _async_states[loop]
def _account_lock(account):
return _async_state()["accounts"].setdefault(str(account), asyncio.Lock())
def _serialized_command(fn):
"""Commands and background work share account locks AND concurrency budget."""
@wraps(fn)
async def wrapped(channel, msg_type, target_id, user_id, *args):
state = _async_state()
async with state["users"].setdefault(str(user_id), asyncio.Lock()):
if fn.__name__ == "_cmd_bind":
match = re.match(r"\.ginkgo\s+(?:bind|绑定)\s+(\S+)", str(args[0]), re.I)
account = match.group(1) if match else ""
else:
mine = await asyncio.to_thread(find_by_uid, user_id)
account = mine.get("account") if mine else ""
async with _account_lock(account):
async with state["sem"]:
return await fn(channel, msg_type, target_id, user_id, *args)
return wrapped
async def _network_thread(fn, *args):
"""Keep the account lock/budget until a cancelled thread call really stops."""
task = asyncio.create_task(asyncio.to_thread(fn, *args))
try:
return await asyncio.shield(task)
except asyncio.CancelledError:
while not task.done():
try:
await asyncio.shield(task)
except asyncio.CancelledError:
continue
except Exception:
break
if not task.cancelled():
task.exception() # retrieve any worker error before propagating cancellation
raise
async def _reply(channel, msg_type, target_id, content):
await send_message(channel, msg_type, target_id, content)
def _switch_text(on):
return "开" if on else "关"
def _push_label(uid):
"""推送目标展示(统一身份:TG 私聊 + 每个已绑定 QQ 私聊镜像)"""
labels = []
for name, _target_id in identity.push_targets(uid):
label = "TG 私聊" if name == "tg" else "%s 私聊" % name.upper()
if label not in labels:
labels.append(label)
return " + ".join(labels) if labels else "无可用通道"
def _push_hint(data):
if data.get("notice_push") or data.get("auto_sign_in"):
return "轮询已运行"
return "通知推送与自动签到都关闭,轮询未运行"
def _format_item(index, item):
return "%d. [%s] %s\n %s\n %s" % (
index, item.get("kinder", ""), _clip(item.get("title") or "", 60),
item.get("time") or "", item.get("link") or NOTICE_PAGE)
def _signin_text(item, ok, message, payload, attempts=None):
return receipt_text(item, ok, message, payload, attempts)
@_serialized_command
async def _cmd_bind(channel, msg_type, target_id, user_id, raw_text):
m = re.match(r"\.ginkgo\s+(?:bind|绑定)\s+(\S+)\s+(\S.*)$", raw_text.strip(), re.I | re.S)
if not m:
await _reply(channel, msg_type, target_id,
"用法: .ginkgo bind <账号> <密码>\n例如 .ginkgo bind 2025211228 密码")
return
account = m.group(1).strip()
password = m.group(2).strip()
if not account.isdigit():
await _reply(channel, msg_type, target_id, "账号应为学号(纯数字)")
return
if not password:
await _reply(channel, msg_type, target_id, "密码不能为空")
return
old_other = await asyncio.to_thread(find_by_account, account)
# 同一身份重复绑定同一账号允许(用于更新密码/重建水位);只有他人占用才拒绝。
# 注意:账号数据里不再保存 channel(统一身份改造后推送目标由 identity 决定),
# 因此这里只能按 user_id 判定归属。
if old_other and str(old_other.get("user_id")) != str(user_id):
await _reply(channel, msg_type, target_id,
"该账号已被其他用户绑定,如确需绑定请联系管理员处理")
return
mine = await asyncio.to_thread(find_by_uid, user_id)
if mine and mine.get("account") != account:
await _reply(channel, msg_type, target_id,
"你已绑定账号 %s,请先 .ginkgo unbind %s 再绑定新账号"
% (mine.get("account"), mine.get("account")))
return
rebind = old_other is not None
await _reply(channel, msg_type, target_id, "正在登录白果云校验账号,请稍候…")
try:
sess = get_session(account, password)
await _network_thread(sess.ensure)
# Only the first successful bind establishes a baseline. A rebind must
# never fetch-and-union arrivals into the old discovery watermark.
seen = []
if not rebind:
notices, busi = await _network_thread(fetch_all, sess)
seen = list(dict.fromkeys(item["key"] for item in notices + busi))
except Exception as exc:
reason, limited = _exception_reason(exc)
if limited and old_other:
await _handle_rate_limited(old_other, reason)
await _reply(channel, msg_type, target_id, "绑定失败:%s" % reason)
return
name = sess.student_name or ""
baseline_note = ("(沿用原水位、待投递通知和签到任务,不吞掉期间新通知)" if rebind else
"(已建立基线:此前 %d 条历史通知不补推)" % len(seen))
def bind_update(data):
if "user_id" in data and str(data["user_id"]) != str(user_id):
return False
# A first-bind contender must not overwrite a concurrent completed bind.
if not rebind and "password" in data:
return False
data.update(password=password, student_name=name, user_id=user_id)
data.setdefault("notice_push", False)
data.setdefault("auto_sign_in", False)
data.setdefault("created", _stamp())
if not rebind:
data.update(seen_keys=seen, last_poll=0)
audit(data, "rebound" if rebind else "bound", account)
try:
data = await asyncio.to_thread(_mutate_account, account, bind_update,
old_other.get("_generation") if rebind else None, not rebind)
except OSError:
await _reply(channel, msg_type, target_id, "绑定失败:状态写入异常")
return
if data is None:
await _reply(channel, msg_type, target_id, "绑定状态已变化,请重新执行绑定")
return
await _reply(channel, msg_type, target_id,
"绑定成功:%s\n通知推送:%s(.ginkgo notice on 开启)\n自动签到:%s(.ginkgo auto-sign-in on 开启)\n%s"
% (name or account, _switch_text(data["notice_push"]),
_switch_text(data["auto_sign_in"]), baseline_note))
async def _cmd_unbind(channel, msg_type, target_id, user_id, raw_text):
m = re.match(r"\.ginkgo\s+(?:unbind|解绑)(?:\s+(\S+))?\s*$", raw_text.strip(), re.I)
if not m:
# 正则失配(如 ".ginkgo unbind 123 456")时 arg 会退化为 "";
# 绝不能按无参处理直接进入删除流程,否则误输入会静默解绑
await _reply(channel, msg_type, target_id,
"用法: .ginkgo unbind [账号]\n账号参数只能是单个学号,请勿包含空格")
return
arg = (m.group(1) or "").strip()
mine = await asyncio.to_thread(find_by_uid, user_id)
if not mine:
await _reply(channel, msg_type, target_id, "你尚未绑定任何白果云账号")
return
if arg and arg != mine.get("account"):
await _reply(channel, msg_type, target_id,
"你绑定的是 %s;若要解绑请发送 .ginkgo unbind %s"
% (mine.get("account"), mine.get("account")))
return
account = mine.get("account")
await asyncio.to_thread(_delete_account, account, mine.get("_generation"))
with _sessions_lock:
_sessions.pop(account, None)
_next_check.pop(account, None)
_keepalive_last.pop(account, None)
_poll_fail.pop(account, None)
_recovery_notice_at.pop(account, None)
await _reply(channel, msg_type, target_id, "已解绑账号 %s" % account)
async def _cmd_status(channel, msg_type, target_id, user_id):
mine = await asyncio.to_thread(find_by_uid, user_id)
if not mine:
await _reply(channel, msg_type, target_id,
"尚未绑定账号,使用 .ginkgo bind <账号> <密码> 绑定")
return
account = mine.get("account")
interval, label = current_interval()
now_ts = _now_ts()
notice_push = bool(mine.get("notice_push"))
auto_sign_in = bool(mine.get("auto_sign_in"))
if notice_push or auto_sign_in:
if not notice_push and interval != 10:
# 仅开自动签到:当前档位暂停轮询,等下一个 10 秒档
next_text = "当前档位暂停(仅在 10 秒档时段轮询)"
else:
left = max(0, int(_next_check.get(account, 0) - now_ts))
next_text = "%d 秒后" % left if left else "即将执行"
elif int(mine.get("rate_limited_until") or 0) > now_ts:
next_text = "未运行(限流冷却中,暂停保活探活)"
elif not mine.get("password"):
next_text = "未运行(缺少密码,保活探活不可用)"
else:
ka = _keepalive_last.get(account, 0)
ka = ka if ka and ka > now_ts else _next_keepalive(now_ts)
next_text = "未常规轮询;下次保活探活 %s(%d 分钟后)" % (
_fmt_ts(ka), max(0, int((ka - now_ts) // 60)))
manual_jobs = [job for job in mine.get("signin_jobs", {}).values()
if signin_source(job) == "manual"]
manual_pending = sum(job.get("status") in ("pending", "running") for job in manual_jobs)
recent_manual = max(manual_jobs,
key=lambda job: job.get("finished_at") or job.get("created_at") or 0,
default=None)
if manual_pending and not (notice_push or auto_sign_in):
next_text = "手动签到任务调度中/等待重试(%d 条)" % manual_pending
lines = [
"白果云账号状态",
"━━━━━━━━━━━━",
"账号:%s(%s)" % (account, mine.get("student_name") or "姓名未知"),
"推送通道:%s" % _push_label(mine.get("user_id")),
"通知推送:%s" % _switch_text(mine.get("notice_push")),
"自动签到:%s" % _switch_text(mine.get("auto_sign_in")),
"手动签到待执行:%d" % manual_pending,
"最近手动任务:%s" % (recent_manual.get("status") if recent_manual else "无"),
"轮询档位:%s" % label,
"下次轮询:%s" % next_text,
"上次轮询:%s" % (_fmt_ts(mine.get("last_poll")) or "尚未轮询"),
]
until = int(mine.get("rate_limited_until") or 0)
if until > _now_ts():
lines.append("限流冷却:剩余 %d 分钟(%s 触发:%s)"
% (max(1, int((until - _now_ts()) // 60)),
mine.get("rate_limited_at") or "未知",
_clip(mine.get("rate_limited_reason") or "", 60)))
await _reply(channel, msg_type, target_id, "\n".join(lines))
@_serialized_command
async def _cmd_notice_list(channel, msg_type, target_id, user_id, count):
mine = await asyncio.to_thread(find_by_uid, user_id)
if not mine:
await _reply(channel, msg_type, target_id,
"尚未绑定账号,使用 .ginkgo bind <账号> <密码> 绑定")
return
count = max(1, min(count, LIST_MAX))
account = mine.get("account")
password = mine.get("password") or ""
try:
sess = get_session(account, password)
notices, busi = await _network_thread(fetch_all, sess)
except Exception as exc:
reason, limited = _exception_reason(exc)
if limited:
await _handle_rate_limited(mine, reason)
await _reply(channel, msg_type, target_id, "获取通知失败:%s" % reason)
return
items = notices + busi
items.sort(key=lambda x: x.get("ts") or 0, reverse=True)
items = items[:count]
if not items:
await _reply(channel, msg_type, target_id, "暂无通知")
return
lines = ["白果云最新通知(%d 条)" % len(items), "━━━━━━━━━━━━"]
for i, item in enumerate(items, 1):
lines.append(_format_item(i, item))
await _reply(channel, msg_type, target_id, "\n".join(lines))
@_serialized_command
async def _cmd_sign(channel, msg_type, target_id, user_id):
"""Fetch current messages and durably enqueue the latest valid activity."""
mine = await asyncio.to_thread(find_by_uid, user_id)
if not mine:
await _reply(channel, msg_type, target_id,
"尚未绑定账号,使用 .ginkgo bind <账号> <密码> 绑定")
return
account = mine.get("account")
try:
sess = get_session(account, mine.get("password") or "")
_notices, busi = await _network_thread(fetch_all, sess)
except Exception as exc:
if isinstance(exc, AuthError):
reason, limited = "账号认证失败,请检查绑定信息或重新绑定", False
else:
reason, limited = _exception_reason(exc)
if limited:
await _handle_rate_limited(mine, reason)
await _reply(channel, msg_type, target_id,
"获取签到活动失败:上游请求过于频繁,已关闭通知推送和自动签到")
else:
await _reply(channel, msg_type, target_id, "获取签到活动失败:%s" % reason)
return
item = _latest_signin_activity(busi)
if not item:
await _reply(channel, msg_type, target_id, "没有找到可签到活动")
return
outcome = {}
def create(latest):
action, key, job = enqueue_signin(latest, item, "manual", _now_ts())
outcome.update(action=action, key=key, run_id=(job or {}).get("run_id"))
updated = await asyncio.to_thread(_mutate_account, account, create, mine.get("_generation"))
if updated is None:
await _reply(channel, msg_type, target_id, "绑定状态已变化,请重试")
return
action, key = outcome.get("action"), outcome.get("key")
job = updated.get("signin_jobs", {}).get(key, {})
if action == "active":
result_text = "同一活动已有%s签到任务待执行,不重复创建" % (
"手动" if signin_source(job) == "manual" else "自动")
elif action == "succeeded":
receipt_key = "signin:" + key + ((":" + str(job.get("run_id"))) if job.get("run_id") else "")
receipt_status = (updated.get("outbox", {}).get(receipt_key) or {}).get("status")
suffix = (",结果通知仍在投递" if receipt_status in ("pending", "sending") else
",结果通知投递失败" if receipt_status == "failed" else "")
result_text = "该活动已成功处理过%s,不再次签到" % suffix
elif action == "reset":
result_text = "旧失败/取消任务已保留审计摘要,已重置为新的手动签到任务并立即调度"
elif action == "created":
result_text = "已创建持久化手动签到任务并立即调度"
else:
result_text = "任务状态异常,未创建手动签到任务,请稍后重试"
old_hint = ""
if item.get("ts") and _now_ts() - item["ts"] > 86400:
old_hint = "\n提示:该活动距今较久,可能已结束"
if action in ("created", "reset"):
old_hint += ",仍已按手动签到入队"
await _reply(channel, msg_type, target_id,
"白果云签到活动\n标题:%s\n签到类型:%s\n时间:%s\n链接:%s\n%s%s" % (
_clip(item.get("title") or "(无标题)", 120),
item.get("signin_type_name") or _signin_type_label(item.get("signin_type")),
item.get("time") or "未知", item.get("link") or "", result_text, old_hint))
async def _cmd_notice_switch(channel, msg_type, target_id, user_id, value):
mine = await asyncio.to_thread(find_by_uid, user_id)
if not mine:
await _reply(channel, msg_type, target_id,
"尚未绑定账号,使用 .ginkgo bind <账号> <密码> 绑定")
return
account = mine["account"]
pause_epoch = mine.get("rate_pause_epoch")
def change(data):
if value == "on" and data.get("rate_pause_epoch") != pause_epoch:
return False
data["notice_push"] = (value == "on")
audit(data, "notice_switch", account, enabled=(value == "on"))
mine = await asyncio.to_thread(_mutate_account, account, change, mine.get("_generation"))
if mine is None:
await _reply(channel, msg_type, target_id, "绑定状态已变化,请重试")
return
if mine["notice_push"]:
_next_check[account] = 0 # 立即触发一次轮询
await _reply(channel, msg_type, target_id,
"已开启通知推送,轮询已运行" + _cooldown_warning(mine))
else:
manual_pending = any(signin_source(job) == "manual"
and job.get("status") in ("pending", "running")
for job in mine.get("signin_jobs", {}).values())
if manual_pending:
suffix = ",手动签到任务仍继续执行"
elif mine.get("auto_sign_in"):
suffix = ",因自动签到仍开启,将在 10 秒档时段轮询"
else:
suffix = ",轮询已停止(每天 11:55 / 23:55 保活探活)"
await _reply(channel, msg_type, target_id, "已关闭通知推送" + suffix)
async def _cmd_auto_sign_in(channel, msg_type, target_id, user_id, value):
mine = await asyncio.to_thread(find_by_uid, user_id)
if not mine:
await _reply(channel, msg_type, target_id,
"尚未绑定账号,使用 .ginkgo bind <账号> <密码> 绑定")
return
account = mine["account"]
pause_epoch = mine.get("rate_pause_epoch")
def change(data):
if value == "on" and data.get("rate_pause_epoch") != pause_epoch:
return False
data["auto_sign_in"] = (value == "on")
if value != "on":
cancel_signins(data, "用户关闭自动签到,已取消剩余重试", source="auto")
audit(data, "signin_switch", account, enabled=(value == "on"))
mine = await asyncio.to_thread(_mutate_account, account, change, mine.get("_generation"))
if mine is None:
await _reply(channel, msg_type, target_id, "绑定状态已变化,请重试")
return
if mine["auto_sign_in"]:
_next_check[account] = 0
await _reply(channel, msg_type, target_id,
"已开启自动签到:发现「你收到来自 xxxxx 的签到」类互动消息时自动签到,结果私聊告知"
+ _cooldown_warning(mine))
else:
manual_pending = any(signin_source(job) == "manual"
and job.get("status") in ("pending", "running")
for job in mine.get("signin_jobs", {}).values())
if manual_pending:
suffix = ",手动签到任务仍继续执行"
elif mine.get("notice_push"):
suffix = ",因通知推送仍开启,轮询继续"
else:
suffix = ",轮询已停止(每天 11:55 / 23:55 保活探活)"
await _reply(channel, msg_type, target_id, "已关闭自动签到" + suffix)
async def handle_ginkgo(channel, msg_type, target_id, user_id, raw_text):
"""dispatch 调用入口。群聊与私聊均可使用;推送一律走私聊。
dispatch 用 create_task 发起且不 retrieve 异常:任何逃出子命令的异常
(损坏的账号 JSON、存储 I/O 错误、脏数据结构等)都必须在此兜底,
打日志并尽量回复用户,避免任务静默死亡、用户零反馈。
"""
try:
await _handle_ginkgo(channel, msg_type, target_id, user_id, raw_text)
except Exception as exc:
import traceback
print("[ginkgo] 指令处理异常 (%s)" % type(exc).__name__)
traceback.print_exc()
try:
await _reply(channel, msg_type, target_id,
"白果云指令执行内部错误(%s),请稍后重试或联系管理员"
% type(exc).__name__)
except Exception:
pass # 回复失败不再二次抛出
async def _handle_ginkgo(channel, msg_type, target_id, user_id, raw_text):
text = (raw_text or "").strip()
body = text[len(".ginkgo"):].strip()
parts = body.split(maxsplit=1)
sub = parts[0].lower() if parts else ""
arg = parts[1].strip() if len(parts) > 1 else ""
if sub in ("", "help", "-h", "帮助"):
await _reply(channel, msg_type, target_id, HELP_TEXT)
return
if sub in ("rule", "规则"):
await _reply(channel, msg_type, target_id, rule_text())
return
if sub in ("bind", "绑定"):
await _cmd_bind(channel, msg_type, target_id, user_id, text)
return
if sub in ("unbind", "解绑"):
await _cmd_unbind(channel, msg_type, target_id, user_id, text)
return
if sub in ("status", "状态"):
await _cmd_status(channel, msg_type, target_id, user_id)
return
if sub in ("sign", "签到"):
if arg:
await _reply(channel, msg_type, target_id, "用法: .ginkgo sign")
else:
await _cmd_sign(channel, msg_type, target_id, user_id)
return
if sub == "notice":
low = arg.lower()
if low in ("on", "off"):
await _cmd_notice_switch(channel, msg_type, target_id, user_id, low)
return
if low in ("help", "-h", "帮助", ""):
await _reply(channel, msg_type, target_id,
"用法: .ginkgo notice <N|on|off>\n• <N> 查看最新 N 条通知(1-%d)\n"
"• on/off 开关通知推送" % LIST_MAX)
return
if not arg.isdigit():
await _reply(channel, msg_type, target_id,
"参数应为正整数或 on/off,例如 .ginkgo notice 5")
return
await _cmd_notice_list(channel, msg_type, target_id, user_id, int(arg))
return
if sub in ("auto-sign-in", "auto_sign_in", "autosignin", "自动签到"):
low = arg.lower()
if low in ("on", "off"):
await _cmd_auto_sign_in(channel, msg_type, target_id, user_id, low)
return
await _reply(channel, msg_type, target_id, "用法: .ginkgo auto-sign-in <on|off>")
return
await _reply(channel, msg_type, target_id,
"未知子指令:%s\n发送 .ginkgo help 查看帮助" % sub)
# ==================== 轮询与推送 ====================
_next_check = {}
_keepalive_last = {} # account -> 最近已触发的保活时段戳(通知与签到都关的账号用)
_running = {} # account -> asyncio.Task(防止同一账号重叠轮询)
PUSH_LOG = os.path.join(STATE_DIR, "push.log")
PUSH_LOG_MAX = 2 * 1024 * 1024
def _push_log(line):
"""推送审计日志:记录每次推送的时间/对象/结果,便于事后核查"""
try:
os.makedirs(STATE_DIR, exist_ok=True)
if os.path.exists(PUSH_LOG) and os.path.getsize(PUSH_LOG) > PUSH_LOG_MAX:
os.replace(PUSH_LOG, PUSH_LOG + ".1")
with open(PUSH_LOG, "a", encoding="utf-8") as f:
f.write("[%s] %s\n" % (_stamp(), line))
except OSError as e:
print("[ginkgo] 推送日志写入失败: %s" % e)
def _cooldown_warning(data):
"""限流冷却期内的提示(仅提示,不阻止用户手动恢复)"""
until = int(data.get("rate_limited_until") or 0)
left = until - _now_ts()
if left <= 0:
return ""
return ("\n注意:%s 因触发限流被自动关闭过,建议再等 %d 分钟"
% (data.get("rate_limited_at") or "上次", max(1, int(left // 60))))
def _poll_account(account, keepalive=False):
"""Fetch only if enabled, then commit discovery and durable work atomically.
keepalive=True 时(账号两个开关都关)只做一次取数探活以刷新登录态,
不写发现水位/seen_keys,避免用户重新开启推送后丢失期间的通知。
"""
data = _load_account(account)
if not data:
return None
enabled = data.get("notice_push") or data.get("auto_sign_in")
if not enabled and not keepalive:
return None
password = data.get("password") or ""
if not password:
return None
notices, busi = fetch_all(get_session(account, password))
if not enabled:
return True
def commit(latest):
# Switches may change while the upstream request is in flight. Never
# restore them from this snapshot, and don't consume disabled arrivals.
if not (latest.get("notice_push") or latest.get("auto_sign_in")):
return False
discover(latest, _annotate_signin_items(notices + busi),
_notice_push_text, _now_ts())
return _mutate_account(account, commit, data.get("_generation"))
async def _notify(data, content, tag="通知", targets=None):
"""异步推送走统一身份扇出:TG 私聊 + 每个已绑定 QQ 私聊镜像。
targets 可选:限定本次扇出的目标(持久化 outbox 重试时只补投未成功目标)。
返回 {(channel_name, target_id): bool};逐目标写 push.log 审计(成功/失败)。
"""
uid = data.get("user_id")
all_targets = identity.push_targets(uid) if targets is None else list(targets)
if not all_targets:
print("[ginkgo] 身份 %s 无可用的推送目标,跳过推送" % uid)
_push_log("%s uid=%s 失败:无可用的推送目标" % (tag, uid))
return {}
results = {}
# 白果云已有持久 outbox 和逐目标去重;这里只替换 render/send 阶段,
# 不再叠加第二层 fanout,避免 TG/QQ 镜像重复。
from agent.modes import ModeStore
from channel import get_channel, send_message
import notification_render
import notifications
for name, target_id in all_targets:
mode = ModeStore().get_mode(name, uid)
rendered = content
if mode == "chat":
event = notifications.Event(
kind="ginkgo",
dedupe_key=f"ginkgo:{uid}:{tag}:{_uuid.uuid4().hex}",
legacy_text=content,
uid=uid,
payload={"text": content},
)
rendered = await notification_render.render_event(event, mode, target_id)
ch = get_channel(name)
ok = False
if ch is not None:
ok = await send_message(ch, "private", target_id, rendered, mirror=False)
results[(name, target_id)] = bool(ok)
brief = _clip(str(content).replace("\n", " / "), 160)
for (name, target_id), ok in results.items():
_push_log("%s uid=%s -> %s/%s %s | %s"
% (tag, uid, name, target_id, "成功" if ok else "失败", brief))
return results
def _notice_push_text(item):
return "【白果云·新%s】\n%s\n%s\n%s" % (
item.get("kinder", "消息"), _clip(item.get("title") or "", 120),
item.get("time") or "", item.get("link") or "")
def _exception_reason(exc):
"""Credential-free exception classification, including requests HTTP errors."""
try:
return safe_error(exc), False
except RateLimitedError:
return "上游请求过于频繁,请稍后重试", True
# ==================== 轮询失败追踪 / 恢复通知 ====================
# 失败细节写本机 nssm.log(带时间戳);失败恢复后的首次成功轮询按阈值推送一条"已恢复"。
POLL_FAIL_NOTICE_COUNT = 3 # 连续失败达到此次数,恢复时也告知(即便失败未单独推送过)
POLL_FAIL_NOTICE_SECONDS = 120 # 或失败持续达到此秒数
RECOVERY_NOTICE_COOLDOWN = 1800 # 同一账号两次"已恢复"通知的最小间隔(秒)
_poll_fail = {} # account -> {count, first_at, last_at, reason, auth, notified}
_recovery_notice_at = {} # account -> 上次"已恢复"通知时间戳
def _dur_text(seconds):
seconds = max(0, int(seconds))
if seconds < 60:
return "%d 秒" % seconds
if seconds < 3600:
return "%d 分钟" % (seconds // 60)
return "%d 小时 %d 分钟" % (seconds // 3600, (seconds % 3600) // 60)
def _failure_detail(exc, reason):
"""本地日志用的失败细节(绝不进入推送文案)。
优先用自有异常文案(AuthError/UpstreamError 等,可能带上游片段);requests 异常
补上"方法 + 路径",**查询串一律剥掉**,避免任何参数/令牌写入日志。
"""
if isinstance(exc, (AuthError, UpstreamError, NoSignInCodeError, UnsupportedSigninType)):
detail = re.sub(r"\s+", " ", str(exc)).strip()
if detail:
return detail[:200]
request = getattr(exc, "request", None)
if request is not None:
path = urlsplit(str(getattr(request, "url", "") or "")).path
method = str(getattr(request, "method", "") or "")
if path:
return ("%s %s" % (method, path)).strip()
return reason
def _note_poll_failure(account, exc, reason, keepalive=False, notified=False):
"""记录一次轮询失败,并把原因写进本机日志(时间戳 + 类型 + 连败次数 + 细节)。"""
now = _now_ts()
state = _poll_fail.get(account)
if state is None:
state = _poll_fail[account] = {"count": 0, "first_at": now, "auth": False,
"notified": False, "reason": reason}
state.update(count=state["count"] + 1, last_at=now, reason=reason,
auth=state["auth"] or isinstance(exc, AuthError),
notified=state["notified"] or notified)
kind = ("认证" if isinstance(exc, AuthError) else
"限流" if isinstance(exc, RateLimitedError) else "瞬时")
print("[ginkgo] %s [%s] %s失败 #%d (%s/%s): %s | %s" % (
account, _stamp(), "保活探活" if keepalive else "轮询", state["count"],
type(exc).__name__, kind, reason, _failure_detail(exc, reason)))
return state
async def _note_poll_recovered(account, snapshot):
"""失败后首次成功:写日志;达到阈值且不在冷却期时推送"已恢复/重新登录成功"。"""
state = _poll_fail.pop(account, None)
if not state:
return
now = _now_ts()
print("[ginkgo] %s [%s] 轮询恢复正常(此前连续失败 %d 次,历时 %s,最近原因:%s)"
% (account, _stamp(), state["count"], _dur_text(now - state["first_at"]),
state["reason"]))
substantial = (state["count"] >= POLL_FAIL_NOTICE_COUNT
or (now - state["first_at"]) >= POLL_FAIL_NOTICE_SECONDS)
if not (state["notified"] or substantial):
return # 一闪而过的失败不打扰用户
last_notice = _recovery_notice_at.get(account)
if last_notice is not None and now - last_notice < RECOVERY_NOTICE_COOLDOWN:
return # 抖动期间避免通知风暴
text = ("【白果云·已恢复】\n%s\n失败 %d 次(历时 %s),最近原因:%s\n恢复时间:%s" % (
"重新登录成功,轮询已恢复正常。" if state["auth"] else "轮询已恢复正常。",
state["count"], _dur_text(now - state["first_at"]),
_clip(state.get("reason") or "", 120), _fmt_ts(now)))
result = await asyncio.to_thread(_mutate_account, account,
lambda latest: enqueue(latest, "control:recovered:" + _uuid.uuid4().hex, "control", text),
(snapshot or {}).get("_generation"))
if result is not None:
_recovery_notice_at[account] = now
def _pause_state(data, reason):
# All switches, cancellations and control alarm share the same disk commit.
data.update(notice_push=False, auto_sign_in=False, rate_limited_at=_stamp(),
rate_pause_epoch=_uuid.uuid4().hex,
rate_limited_until=int(_now_ts() + RATE_LIMIT_COOLDOWN),
rate_limited_reason=str(reason)[:200])
cancel_signins(data, "触发限流,已取消剩余自动重试:%s" % reason, source="auto")
text = ("【白果云·已暂停】\n检测到白果云频率限制:%s\n"
"已关闭通知推送和自动签到,自动签到重试已取消。\n"
"手动签到任务会在冷却结束后继续;建议冷却 %d 分钟后手动恢复:\n"
".ginkgo notice on / .ginkgo auto-sign-in on"
% (reason, RATE_LIMIT_COOLDOWN // 60))
enqueue(data, "control:limited:" + _uuid.uuid4().hex, "control", text)
audit(data, "rate_limited", data["account"], reason=str(reason))
async def _handle_rate_limited(data, exc):
account = data.get("account")
# Do not interpolate arbitrary upstream exception strings into persisted logs.
reason = "上游请求过于频繁,请稍后重试"
state = _poll_fail.get(account)
if state is not None:
state["notified"] = True # 限流暂停也会推送,恢复时应当告知
result = await asyncio.to_thread(_mutate_account, account,
lambda latest: _pause_state(latest, reason), data.get("_generation"))
_next_check.pop(account, None)
return result
async def _deliver_outbox(account):
snapshot = await asyncio.to_thread(_load_account, account)
if not snapshot:
return
# Controls/receipts cannot be starved behind paused notices or a large backlog.
rows = sorted(snapshot.get("outbox", {}).items(),
key=lambda pair: (pair[1].get("kind") == "notice", pair[1].get("created_at", 0)))
count = 0
for key, row in rows:
if count >= PUSH_MAX:
break
if row.get("status") in OUTBOX_TERMINAL:
continue # sent/failed 都是终态,不再重试
if (row.get("next_attempt_at") or 0) > _now_ts():
continue
claim = _uuid.uuid4().hex
latest = await asyncio.to_thread(_mutate_account, account,
lambda data: claim_outbox(data, key, claim, _now_ts()), snapshot["_generation"])
if not latest:
continue
count += 1
acknowledged, reason, succeeded = False, "", []
try:
# Recheck after reservation; off/unbind are deliberately not blocked
# behind network semaphores. Already in-flight sends cannot be undone.
current = await asyncio.to_thread(_load_account, account)
if not current or current["_generation"] != latest["_generation"]:
return
claimed = (latest.get("outbox") or {}).get(key) or {}
# 逐目标去重:sent_targets 里已有的目标绝不再发(避免重复轰炸 TG)
done = set(claimed.get("sent_targets") or [])
wanted = [t for t in identity.push_targets(current.get("user_id"))
if target_key(t[0], t[1]) not in done]
if row.get("kind") == "notice" and not current.get("notice_push"):
reason = "notice delivery disabled"
elif not wanted:
if done:
acknowledged = True # 所有目标此前都已投递成功
else:
# 无可用推送目标:与旧 _notify 行为一致,仍写审计行
reason = "无可用的推送目标"
print("[ginkgo] 身份 %s 无可用的推送目标,跳过推送"
% current.get("user_id"))
_push_log("%s uid=%s 失败:无可用的推送目标"
% (row.get("kind", "通知"), current.get("user_id")))
else:
results = await _notify(current, claimed.get("text", ""),
tag=row.get("kind", "通知"), targets=wanted)
succeeded = [t for t, ok in results.items() if ok]
acknowledged = bool(results) and len(succeeded) == len(results)
if not acknowledged:
missing = ["%s/%s" % t for t, ok in results.items() if not ok]
reason = "未成功目标: %s" % ",".join(missing)
except Exception as exc:
reason = "delivery error (%s)" % type(exc).__name__
await asyncio.to_thread(_mutate_account, account,
lambda data: ack_outbox(data, key, claim, acknowledged, reason,
_now_ts(), succeeded),
latest["_generation"])
async def _run_signin_jobs(account):
snapshot = await asyncio.to_thread(_load_account, account)
if not snapshot:
return
if not snapshot.get("auto_sign_in") and any(
job.get("status") in ("pending", "running")
and signin_source(job) == "auto"
for job in snapshot.get("signin_jobs", {}).values()):
snapshot = await asyncio.to_thread(_mutate_account, account,
lambda data: (False if data.get("auto_sign_in") else
cancel_signins(data, "自动签到已关闭,取消剩余重试",
source="auto")), snapshot["_generation"])
if not snapshot:
return
due = [(key, job) for key, job in snapshot.get("signin_jobs", {}).items()
if (job.get("status") in ("pending", "running")
and signin_enabled(snapshot, job)
and (job.get("next_attempt_at") or 0) <= _now_ts())]
for key, _job in due[:1]:
claim = _uuid.uuid4().hex
latest = await asyncio.to_thread(_mutate_account, account,
lambda data: claim_signin(data, key, claim, _now_ts()), snapshot["_generation"])
if not latest:
continue
job = latest.get("signin_jobs", {}).get(key, {})
if job.get("claim_id") != claim:
continue # A crashed sixth attempt was finalized, never attempt seven.
if job.get("status") != "running":
await asyncio.to_thread(_mutate_account, account,
lambda data: abandon_signin_claim(data, key, claim), latest["_generation"])
continue
current = await asyncio.to_thread(_load_account, account)
if not current or current.get("_generation") != latest["_generation"]:
return
current_job = current.get("signin_jobs", {}).get(key, {})
if (not signin_enabled(current, current_job)
or current_job.get("status") != "running"
or current_job.get("claim_id") != claim):
if current_job.get("claim_id") == claim:
await asyncio.to_thread(_mutate_account, account,
lambda data: abandon_signin_claim(data, key, claim), latest["_generation"])
return
try:
result = await _network_thread(
call_sign_in_api, account, current.get("password") or "",
job["item"].get("link") or "", job.get("attempts", 0) > 1)
if not isinstance(result, (tuple, list)) or len(result) != 4:
raise ValueError("malformed signin transport result")
_ok, message, payload, limited = result
message = str(message or "未返回明确提交成功结果")
# Explicit throttling always wins, even if commit.success says True.
limited = bool(limited) or is_rate_limited(None, {"message": message})
except Exception as exc:
message, limited = _exception_reason(exc)
payload = None
if limited:
await _handle_rate_limited(latest, message)
return
await asyncio.to_thread(_mutate_account, account,
lambda data: complete_signin(data, key, claim, message, payload, _now_ts()),
latest["_generation"])
async def _poll_and_push(data, sem=None, do_poll=True, keepalive=False):
"""Serialize account network work; all callers share the global budget."""
account = data.get("account")
try:
async with _account_lock(account):
async with _async_state()["sem"]:
current = await asyncio.to_thread(_load_account, account)
if not current or current["_generation"] != data.get("_generation"):
return
await _deliver_outbox(account)
if do_poll:
try:
outcome = await _network_thread(_poll_account, account, keepalive)
if outcome:
# 只有真的完成取数才算成功;被开关变化跳过(None)不算恢复
await _note_poll_recovered(account, current)
except Exception as exc:
reason, limited = _exception_reason(exc)
if limited:
_note_poll_failure(account, exc, reason, keepalive, notified=True)
await _handle_rate_limited(current, reason)
elif isinstance(exc, AuthError):
# 保活探活失败不打扰用户;正常轮询失败会推送"登录失败"
_note_poll_failure(account, exc, reason, keepalive,
notified=not keepalive)
if not keepalive:
await asyncio.to_thread(_mutate_account, account,
lambda latest: enqueue(latest,
"control:auth:" + str(int(_now_ts() // 600)), "control",
"【白果云·登录失败】\n%s\n如已修改密码请重新绑定" % reason), current["_generation"])
_next_check[account] = _now_ts() + 600
else:
_note_poll_failure(account, exc, reason, keepalive)
await _deliver_outbox(account)
await _run_signin_jobs(account)
await _deliver_outbox(account)
except Exception as exc:
# Persisted reservations are recovered after their lease, without stale writes.
print("[ginkgo] %s 工作异常 (%s)" % (account, type(exc).__name__))
async def monitor_loop():
"""One-second durable retry scheduling, independent from discovery intervals."""
print("[ginkgo] 白果云通知轮询已启动(并发上限 %d)" % ACCOUNT_CONCURRENCY)
try:
while True:
try:
interval, _label = current_interval()
now = _now_ts()
for account, task in list(_running.items()):
if task.done():
_running.pop(account, None)
if not task.cancelled():
task.result()
for data in await asyncio.to_thread(list_accounts, True):
account = data.get("account")
if not account or account in _running:
continue
notice_push = bool(data.get("notice_push"))
auto_sign_in = bool(data.get("auto_sign_in"))
enabled = notice_push or auto_sign_in
keepalive = False
keepalive_slot = None
if not enabled:
# 通知与签到都关:不常规轮询,仅每天 11:55 / 23:55 各保活探活
# 一次(每次时段有 KEEPALIVE_GRACE 秒宽限,跨重启可补触发;
# 限流冷却期内跳过,避免破坏限流自停)
paused = int(data.get("rate_limited_until") or 0) > now
if not paused and data.get("password"):
keepalive_slot = _due_keepalive(now, _keepalive_last.get(account, 0))
if keepalive_slot:
keepalive = True
elif not notice_push and interval != 10:
# 仅开自动签到(通知推送关闭):只在 10 秒档时段轮询,
# 其余时段停止轮询(签到活动只在上课时段出现)
enabled = False
do_poll = bool((enabled or keepalive) and now >= _next_check.get(account, 0))
if not enabled and not keepalive:
_next_check.pop(account, None)
if not do_poll and not has_due(data, now):
continue
if do_poll:
_next_check[account] = now + interval
if keepalive_slot:
# 确认本次真的会执行取数探活(通过 _next_check 检查)后才登记
# 槽位;先登记后跳过的旧写法会让该时段当天不再补触发
_keepalive_last[account] = keepalive_slot
print("[ginkgo] %s [%s] 触发保活探活(通知与签到都关)"
% (account, _stamp()))
_running[account] = asyncio.create_task(
_poll_and_push(data, do_poll=do_poll, keepalive=keepalive))
except Exception as exc:
print("[ginkgo] 轮询调度异常 (%s)" % type(exc).__name__)
await asyncio.sleep(1)
finally:
# Do not release account locks while to_thread network calls still run.
# Graceful shutdown drains workers; abrupt death uses persisted leases.
if _running:
await asyncio.gather(*list(_running.values()), return_exceptions=True)
_running.clear()