diff --git a/loomy2api/__init__.py b/loomy2api/__init__.py index 9db7ee5..4aba5ef 100644 --- a/loomy2api/__init__.py +++ b/loomy2api/__init__.py @@ -19,6 +19,14 @@ from .account import Account, AccountClient, AccountError # noqa: F401 from .config import Config, load_config, PROJECT_ROOT # noqa: F401 +from .onboarding import ( # noqa: F401 + ONBOARDING_TOTAL, + TASK_POINTS, + TASK_TITLES, + OnboardingError, + claim_all, + claim_for_account, +) from .pool import AccountPool, PoolError # noqa: F401 from .server import Gateway, Logger, serve # noqa: F401 from .upstream import ModelGateway, UpstreamError # noqa: F401 @@ -30,4 +38,6 @@ "Config", "load_config", "PROJECT_ROOT", "Gateway", "Logger", "serve", "ModelGateway", "UpstreamError", + "ONBOARDING_TOTAL", "TASK_POINTS", "TASK_TITLES", + "OnboardingError", "claim_all", "claim_for_account", ] diff --git a/loomy2api/cli.py b/loomy2api/cli.py index a2af943..24c368f 100644 --- a/loomy2api/cli.py +++ b/loomy2api/cli.py @@ -7,6 +7,7 @@ loomy2api login [name ...] log in / refresh sessions loomy2api sms send an SMS code (SMS login path) loomy2api verify + loomy2api onboarding [name ...] claim the 8 newbie tasks (10 000 points) loomy2api models list upstream models loomy2api quota show quota of every account loomy2api chat "prompt" one-shot chat through the gateway @@ -23,6 +24,7 @@ from . import constants as C from .config import load_config from .account import AccountClient, Account, AccountError +from .onboarding import ONBOARDING_TOTAL, TASK_POINTS, TASK_TITLES, claim_for_account from .pool import AccountPool, PoolError from .server import Gateway, Logger, serve from .upstream import ModelGateway @@ -185,6 +187,45 @@ def cmd_identity(cfg, args) -> int: return 0 +def cmd_onboarding(cfg, args) -> int: + """Claim the eight newbie tasks — a pure API claim, no model tokens spent.""" + pool = _pool(cfg) + names = list(args.names) or [a.name for a in pool.accounts] + if not names: + print("账号池为空:先 `loomy2api add` 或 `loomy2api verify` 加一个账号") + return 1 + + total_points = 0 + failed = 0 + for name in names: + acc = pool.get(name) + if acc is None: + print(f"✗ 没有名为 {name} 的账号") + failed += 1 + continue + print(f"—— {name} ——") + result = claim_for_account(pool, acc, log=print) + if not result.get("ok"): + print(f"✗ {name}: {result.get('error')}") + failed += 1 + continue + for item in result["claimed"]: + flag = "(此前已完成,幂等重放)" if item["already_completed"] else "" + print(f" ✓ {item['title']} +{item['points']}{flag}") + if result["skipped"]: + print(f" · 已完成跳过 {len(result['skipped'])} 项:" + + "、".join(TASK_TITLES.get(k, k) for k in result["skipped"])) + for item in result["failed"]: + print(f" ✗ {item['title']}:{item['error']}") + total_points += result.get("claimed_points", 0) + print(f" 本次 +{result['claimed_points']},累计 {result['earned']}/{result['total']}") + pool.save(force=True) + print("-" * 50) + print(f"合计本次领取 {total_points} 积分(满分 {ONBOARDING_TOTAL})" + + (f",{failed} 个账号失败" if failed else "")) + return 1 if failed else 0 + + def cmd_models(cfg, args) -> int: pool = _pool(cfg) client = ModelGateway(cfg) @@ -285,6 +326,10 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument("msgid") p.set_defaults(func=cmd_verify) + p = sub.add_parser("onboarding", help="领取新手任务(8 项,合计 10000 积分)") + p.add_argument("names", nargs="*", help="账号名(缺省=全部)") + p.set_defaults(func=cmd_onboarding) + p = sub.add_parser("identity", help="查看/重绑账号的设备标识") p.add_argument("name") p.add_argument("--rebind", action="store_true", help="生成新标识并重新登录") diff --git a/loomy2api/onboarding.py b/loomy2api/onboarding.py new file mode 100644 index 0000000..e9109fc --- /dev/null +++ b/loomy2api/onboarding.py @@ -0,0 +1,261 @@ +"""Loomy onboarding tasks — 10 000 points, claimable purely through the API. + +Ported from ``dsh-codearts-auth``'s ``src/loomy-onboarding.ts``. + +The upstream does **not** verify the prerequisite behaviour. In the official +Electron client the completion conditions are evaluated locally +(``onboarding-service.js``), and the server only does "idempotent set + add +points". Measured 2026-09-26: eight bare ``POST /complete`` calls took a fresh +account from 0 to 10 000 points without sending a single message, generating a +PPT or installing a skill. So this implementation is a **pure API claim** and +spends zero model tokens. + +Endpoints (model gateway ``loomyad.xunfei.cn/api/v1``):: + + GET /onboarding/tasks → {tasks: {key: bool}, earned, total} + POST /onboarding/tasks/complete body {key} → {alreadyCompleted, balance} + +Three rules carried over verbatim from the reference implementation: + +* idempotency is judged by the response body's ``alreadyCompleted`` — **not** + the HTTP status, **not** ``code`` (already-completed replays count as wins); +* the server's ``earned`` is **not** trusted — points are recomputed locally + from :data:`TASK_POINTS` (the client does the same); +* the report body carries **only** ``key`` — no device fingerprint, no version, + no channel. +""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List, Optional, Tuple + +__all__ = [ + "AUTH_ERROR_CODE", + "BAD_REQUEST_CODE", + "OK_CODE", + "ONBOARDING_TOTAL", + "TASK_POINTS", + "TASK_TITLES", + "OnboardingError", + "claim_all", + "claim_for_account", + "complete_task", + "compute_earned", + "fetch_tasks", + "normalize_tasks", +] + +OK_CODE = "000000" +BAD_REQUEST_CODE = "100001" +#: session expired / not logged in — must stop the whole batch, not retry. +AUTH_ERROR_CODE = "100002" + +#: task key → points. Sum is exactly 10 000; the order is the official order. +TASK_POINTS: Dict[str, int] = { + "first_message": 500, + "pick_skill": 1000, + "generate_ppt": 1500, + "set_schedule": 1000, + "install_skill": 1500, + "configure_remote": 1000, + "create_soul": 1500, + "share_soul": 2000, +} + +#: task key → Chinese title (the client's registry labels). +TASK_TITLES: Dict[str, str] = { + "first_message": "发送你的第一条消息", + "pick_skill": "试试选择一个技能", + "generate_ppt": "生成第一份 PPT", + "set_schedule": "设置定时任务", + "install_skill": "在技能广场安装一个技能", + "configure_remote": "配置远程控制", + "create_soul": "创建你的第一个搭子", + "share_soul": "把搭子分享给朋友", +} + +ONBOARDING_TOTAL = 10_000 + + +class OnboardingError(Exception): + """Business-level failure (Loomy always answers HTTP 200, code in the body).""" + + def __init__(self, message: str, code: str = "", *, retryable: bool = False) -> None: + super().__init__(message) + self.code = code + self.retryable = retryable + + @property + def auth_failed(self) -> bool: + return self.code == AUTH_ERROR_CODE + + +# ---------------------------------------------------------------- helpers + + +def compute_earned(tasks: Dict[str, bool]) -> int: + """Points already claimed, computed **locally** (unknown keys ignored).""" + return sum(points for key, points in TASK_POINTS.items() if tasks.get(key) is True) + + +def normalize_tasks(raw: Any) -> Dict[str, bool]: + """Keep the eight known keys, coerce everything to bool, fill missing with False.""" + source = raw if isinstance(raw, dict) else {} + return {key: source.get(key) is True for key in TASK_POINTS} + + +def _request(gateway, session: str, path: str, payload: Optional[Dict[str, Any]] = None, + method: str = "POST") -> Any: + """One business call; unwraps ``{code, desc, data}``. + + ``AUTH_ERROR_CODE`` is raised as-is so the caller can abort the batch + instead of firing a string of doomed requests. + """ + try: + status, _headers, body = gateway.call(session, path, payload or {}, method=method) + except Exception as exc: # noqa: BLE001 + raise OnboardingError(f"{path} 请求失败:{exc}", code="NETWORK", + retryable=True) from exc + + try: + parsed = json.loads(body.decode("utf-8")) + except Exception as exc: # noqa: BLE001 + raise OnboardingError( + f"{path} 响应不是 JSON(HTTP {status}):" + f"{body[:200].decode('utf-8', 'replace')}", code=f"HTTP_{status}", + retryable=status >= 500) from exc + + if not isinstance(parsed, dict): + return parsed + + code = str(parsed.get("code") or parsed.get("errorCode") or "") + if code and code != OK_CODE: + message = parsed.get("desc") or parsed.get("message") or code + raise OnboardingError(f"{path} 失败:{message}", code=code, + retryable=code in (BAD_REQUEST_CODE, AUTH_ERROR_CODE)) + return parsed.get("data") + + +# ---------------------------------------------------------------- API + + +def fetch_tasks(gateway, session: str) -> Dict[str, Any]: + """Query onboarding state. + + ``earned`` is recomputed locally — the server's value is only a hint and has + historically disagreed with the client's table. + """ + data = _request(gateway, session, "onboarding/tasks", method="GET") + record = data if isinstance(data, dict) else {} + tasks = normalize_tasks(record.get("tasks")) + total = record.get("total") + return { + "tasks": tasks, + "earned": compute_earned(tasks), + "total": int(total) if isinstance(total, (int, float)) and total else ONBOARDING_TOTAL, + } + + +def complete_task(gateway, session: str, key: str) -> Dict[str, Any]: + """Claim one task. A replay (``alreadyCompleted: true``) counts as success.""" + if key not in TASK_POINTS: + # Same as the official client: refuse locally instead of sending a + # request that is guaranteed to fail. + raise OnboardingError(f"未知的 task key: {key}", code=BAD_REQUEST_CODE) + data = _request(gateway, session, "onboarding/tasks/complete", {"key": key}) + record = data if isinstance(data, dict) else {} + balance = record.get("balance") + return { + "key": key, + "points": TASK_POINTS[key], + "title": TASK_TITLES.get(key, key), + "already_completed": record.get("alreadyCompleted") is True, + "balance": int(balance) if isinstance(balance, (int, float)) else None, + } + + +def claim_all(gateway, session: str) -> Dict[str, Any]: + """Claim every outstanding task, serially, skipping what is already done. + + Serial on purpose (the official client does the same). Already-completed + keys are **skipped without a request**; an auth failure aborts the batch + immediately. + """ + state = fetch_tasks(gateway, session) + claimed: List[Dict[str, Any]] = [] + skipped: List[str] = [] + failed: List[Dict[str, Any]] = [] + tasks = dict(state["tasks"]) + + for key in TASK_POINTS: + if state["tasks"].get(key) is True: + skipped.append(key) + continue + try: + result = complete_task(gateway, session, key) + except OnboardingError as exc: + failed.append({"key": key, "title": TASK_TITLES.get(key, key), + "error": str(exc), "code": exc.code}) + if exc.auth_failed: + # Session is dead — every later call would fail too. + break + continue + # A replay counts as claimed as well (idempotent). + claimed.append(result) + tasks[key] = True + + return { + "claimed": claimed, + "skipped": skipped, + "failed": failed, + "earned": compute_earned(tasks), + "total": state["total"], + "claimed_points": sum(item["points"] for item in claimed), + } + + +def claim_for_account(pool, account, *, log=None) -> Dict[str, Any]: + """Convenience wrapper: make sure the session is fresh, claim, refresh quota. + + Never raises for business failures — the panel and the CLI both want a + per-account result object rather than an exception. + """ + emit = log or (lambda _msg: None) + result: Dict[str, Any] = {"name": account.name, "ok": False} + try: + pool.ensure_session(account) + except Exception as exc: # noqa: BLE001 + result["error"] = f"session 不可用:{exc}" + return result + + try: + outcome = claim_all(pool.gateway, account.session) + except OnboardingError as exc: + result["error"] = str(exc) + result["code"] = exc.code + if exc.auth_failed: + emit(f"[新手任务] {account.name} session 已失效,请重新登录") + return result + + result.update({ + "ok": True, + "claimed": outcome["claimed"], + "skipped": outcome["skipped"], + "failed": outcome["failed"], + "claimed_points": outcome["claimed_points"], + "earned": outcome["earned"], + "total": outcome["total"], + }) + emit(f"[新手任务] {account.name} 领取完成:本次 +{outcome['claimed_points']} 积分," + f"已完成 {len(outcome['skipped'])}/{len(TASK_POINTS)}," + f"累计 {outcome['earned']}/{outcome['total']}") + try: + pool.refresh_quota(account) + except Exception: # noqa: BLE001 + pass + try: + pool.save() + except Exception: # noqa: BLE001 + pass + return result diff --git a/loomy2api/panel.py b/loomy2api/panel.py index 6b78511..4ceb4ca 100644 --- a/loomy2api/panel.py +++ b/loomy2api/panel.py @@ -15,6 +15,8 @@ ``POST /api/panel/accounts/remove`` delete an account ``POST /api/panel/accounts/renew`` force re-login + quota refresh ``POST /api/panel/accounts/identity`` inspect / regenerate the account identity +``POST /api/panel/sms/send`` SMS login step 1: send the code → msgid +``POST /api/panel/sms/verify`` SMS login step 2: code → session → account ``GET /api/panel/logs`` tail of the gateway log """ @@ -25,6 +27,7 @@ from typing import Any, Dict, Optional from .account import Account, AccountError +from .constants import SESSION_EXPIRE_SECONDS from .pool import PoolError __all__ = ["Panel"] @@ -218,6 +221,91 @@ def identity(self, payload: Dict[str, Any]) -> Dict[str, Any]: return {"ok": True, "name": name, "identity": acc.identity_view(), "state": self.state()} + def send_sms(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """SMS login step 1 — send the code, hand the ``msgid`` back to the page. + + The panel keeps the msgid in the browser (it is only valid for 5 minutes) + so step 2 can be a separate click without any server-side session state. + """ + phone = str(payload.get("loginid") or payload.get("phone") or "").strip() + if not phone: + raise PoolError("需要手机号 / phone is required") + try: + data = self.pool.client.send_sms_code(phone) + except AccountError as exc: + raise PoolError(f"验证码发送失败:{exc}") from exc + record = data.get("data") if isinstance(data.get("data"), dict) else data + msgid = str((record or {}).get("msgid") or "").strip() + if not msgid: + # Never hand back an empty msgid: step 2 would fail with a message + # that has nothing to do with the real cause. + raise PoolError("短信响应缺少 msgid(请稍后重试)") + self.log(f"[panel] 已向 {mask_phone(phone)} 下发短信验证码") + return {"ok": True, "phone": phone, "msgid": msgid} + + def verify_sms(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """SMS login step 2 — exchange the code for a session and store the account. + + Creates the account when the name is new, otherwise refreshes the + existing one (so "re-login by SMS" works for an expired account too). + """ + name = str(payload.get("name") or "").strip() or "main" + phone = str(payload.get("loginid") or payload.get("phone") or "").strip() + code = str(payload.get("code") or "").strip() + msgid = str(payload.get("msgid") or "").strip() + if not phone or not code or not msgid: + raise PoolError("手机号 / 验证码 / msgid 都不能为空 / " + "phone, code and msgid are all required") + try: + got = self.pool.client.login_by_sms(phone, code, msgid) + except AccountError as exc: + raise PoolError(f"短信登录失败:{exc}") from exc + + acc = self.pool.get(name) + created = acc is None + if acc is None: + acc = self.pool.add_account(name, loginid=phone) + acc.session = got["session"] + acc.userid = got.get("userid", "") or acc.userid + if phone: + acc.loginid = phone + acc.obtained_at = int(time.time()) + acc.expire_at = acc.obtained_at + SESSION_EXPIRE_SECONDS + acc.cooldown_until = 0.0 + self.pool.refresh_quota(acc) + self.pool.save(force=True) + self.log(f"[panel] {name} 短信登录成功 userid={acc.userid} " + f"session={acc.session[:8]}…{'(新建)' if created else '(续期)'}") + return {"ok": True, "name": name, "created": created, + "userid": acc.userid, "days_left": acc.days_left, + "state": self.state()} + + def onboarding(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """Claim the eight newbie tasks for one account (or all of them). + + Pure API claim — no model tokens are spent (see ``onboarding.py``). + """ + from .onboarding import claim_for_account + + name = str(payload.get("name") or "").strip() + if name: + targets = [self.pool.get(name)] + if targets[0] is None: + raise PoolError(f"没有名为 {name} 的账号 / no such account: {name}") + else: + targets = list(self.pool.accounts) + if not targets: + raise PoolError("账号池为空 / account pool is empty") + + results = [claim_for_account(self.pool, acc, log=self.log) for acc in targets] + self.pool.save(force=True) + return { + "ok": all(r.get("ok") for r in results), + "results": results, + "claimed_points": sum(r.get("claimed_points", 0) for r in results), + "state": self.state(), + } + def logs(self, lines: int = 200) -> Dict[str, Any]: path = self.gw.log.path lines = max(10, min(int(lines or 200), 2000)) diff --git a/loomy2api/server.py b/loomy2api/server.py index 4e9c1ce..584cdc0 100644 --- a/loomy2api/server.py +++ b/loomy2api/server.py @@ -353,6 +353,9 @@ def _panel_api(self, path: str) -> None: "/api/panel/accounts/remove": panel.remove_account, "/api/panel/accounts/renew": panel.renew_account, "/api/panel/accounts/identity": panel.identity, + "/api/panel/sms/send": panel.send_sms, + "/api/panel/sms/verify": panel.verify_sms, + "/api/panel/onboarding": panel.onboarding, "/api/panel/refresh": lambda _p: panel.refresh(_p), } handler = handlers.get(path) diff --git a/loomy2api/web/index.html b/loomy2api/web/index.html index ff5c2d7..5c30e63 100644 --- a/loomy2api/web/index.html +++ b/loomy2api/web/index.html @@ -112,6 +112,18 @@

添加账号 add account

密码需要先在讯飞账号中心设置一次;添加成功后会自动为本账号随机生成一套设备标识(devid / 校园设备号)并绑定,之后每次续期都用同一套。
+ +

短信验证码登录 sms login

+
+
+
+
+
+ + +
+
+
没设过密码、或密码登录被风控时走这里:先点「发送验证码」,收到短信后填进验证码框再点「验证并登录」。验证码 5 分钟内有效;账号名留空则用 main,同名账号会被续期而不是重复添加。
@@ -203,6 +215,7 @@

运行日志 recent log

+ '' + (id.devid || '—') + '' + '
' + '' + + '' + '' + '' + '' @@ -225,12 +238,21 @@

运行日志 recent log

identity: ['/api/panel/accounts/identity', {name, regenerate: true}], toggle: ['/api/panel/accounts/update', {name, enabled: value}], remove: ['/api/panel/accounts/remove', {name}], + onboarding: ['/api/panel/onboarding', {name}], }; if (kind === 'remove' && !confirm('确认删除账号 ' + name + '?')) return; const [path, body] = map[kind]; try { const res = await api(path, {method: 'POST', body: JSON.stringify(body)}); - if (res.error) { toast(name + ':' + res.error, 'err'); } + if (kind === 'onboarding') { + const r = (res.results || [])[0] || {}; + if (r.ok) { + toast(name + ' 领取完成:本次 +' + (r.claimed_points || 0) + ' 积分,累计 ' + + (r.earned || 0) + '/' + (r.total || 10000), 'ok'); + } else { + toast(name + ' 领取失败:' + (r.error || '未知原因'), 'err'); + } + } else if (res.error) { toast(name + ':' + res.error, 'err'); } else if (kind === 'identity') { toast(name + ' 已换新设备标识:' + res.identity.devid, 'ok'); } else { toast(name + ' 操作完成', 'ok'); } if (res.state) render(res.state); else load(); @@ -239,6 +261,52 @@

运行日志 recent log

} } +// ── 短信验证码登录(两步:发码 → 用码换 session) +let smsMsgid = ''; + +async function smsSend() { + const phone = $('sms-phone').value.trim(); + if (!phone) return toast('先填手机号', 'err'); + $('sms-send').disabled = true; + try { + const res = await api('/api/panel/sms/send', {method: 'POST', body: JSON.stringify({loginid: phone})}); + smsMsgid = res.msgid; + $('sms-hint').innerHTML = '验证码已发到 ' + phone + '(5 分钟内有效)——' + + '把它填进上面的「验证码」框,再点「验证并登录」。'; + toast('验证码已发送', 'ok'); + } catch (e) { + toast('发送失败:' + e.message, 'err'); + } finally { + $('sms-send').disabled = false; + } +} + +async function smsVerify() { + const phone = $('sms-phone').value.trim(); + const code = $('sms-code').value.trim(); + const name = $('sms-name').value.trim() || 'main'; + if (!phone) return toast('先填手机号', 'err'); + if (!code) return toast('先填验证码', 'err'); + if (!smsMsgid) return toast('请先点「发送验证码」', 'err'); + $('sms-verify').disabled = true; + try { + const res = await api('/api/panel/sms/verify', { + method: 'POST', + body: JSON.stringify({name, loginid: phone, code, msgid: smsMsgid}), + }); + toast((res.created ? '已添加 ' : '已续期 ') + res.name + + '(' + (res.days_left === null ? '—' : res.days_left.toFixed(1) + ' 天') + ')', 'ok'); + $('sms-code').value = ''; + smsMsgid = ''; + $('sms-hint').innerHTML = '登录成功。要再登一个账号就重新填手机号并点「发送验证码」。'; + if (res.state) render(res.state); else load(); + } catch (e) { + toast('登录失败:' + e.message, 'err'); + } finally { + $('sms-verify').disabled = false; + } +} + async function addAccount() { const body = { name: $('f-name').value.trim(), @@ -269,6 +337,8 @@

运行日志 recent log

$('refresh').onclick = () => load(true).then(loadLog); $('add').onclick = addAccount; +$('sms-send').onclick = smsSend; +$('sms-verify').onclick = smsVerify; $('loadLog').onclick = loadLog; $('saveKey').onclick = () => { KEY = $('apiKey').value.trim();