magicrew-auth 0.3.26 → 0.3.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -166,6 +166,168 @@ export function loosenEmptyItems(node) {
166
166
  })
167
167
  }
168
168
 
169
+ /**
170
+ * 修 **`items` 子树内部**的 schema:上游的 JSON Schema 校验器对数组元素的
171
+ * schema 比对根节点**更严**,下面这些形态在 `items` 子树里一律 400:
172
+ *
173
+ * anyOf / oneOf / allOf / not / const → 400
174
+ * if / then / else → 400
175
+ * {}(空 schema) → 400
176
+ *
177
+ * ⚠ 同样形态放在**顶层 parameters** 里上游**收**(200)—— 只有 `items` 子树
178
+ * 里才炸。所以限制必须精确落在 `items` 之下,不能全局摊平 union。
179
+ *
180
+ * 实测(2026-10-28~29,直打国内站 `i-magic-service.letsmagic.cn`,
181
+ * deepseek-v4.1-flash,同一把 key、同一份请求,只改这一个字段;
182
+ * `_probe/items-subtree-upstream.mjs` 可复跑):
183
+ *
184
+ * items 内部 {x:{anyOf:[{type:"string"},{type:"null"}]}} → 400
185
+ * items 内部 {x:{oneOf:[…]}} → 400
186
+ * items 内部 {x:{allOf:[…]}} → 400
187
+ * items 内部 {x:{not:{type:"string"}}} → 400
188
+ * items 内部 {x:{const:"a"}} → 400
189
+ * items 内部 {x:{if:…,then:…}} → 400
190
+ * items 内部 {x:{}} → 400
191
+ * items 内部 {x:{type:["object","string",…,"null"]}} → 200 ← 「任意值」的合法写法
192
+ * items 内部 {x:{type:"object"}} → 200
193
+ * items 内部 {x:{type:"string",pattern:"^a$",enum:[…]}} → 200
194
+ * items 内部 {x:{$ref:"#/$defs/s"}}, $defs 同层 → 200
195
+ * items 是数组(tuple 形式) 国内站 → 200,**国际站 → 400**(两站不一致)
196
+ * 顶层 {x:{anyOf:…}} / {x:{const:…}} / {x:{not:…}} / {x:{}} → 200
197
+ * (国内站 `i-magic-service.letsmagic.cn` 与国际站 `magic-service.magicrew.ai`
198
+ * 逐条对比过:除 tuple 形式外结论一致)
199
+ *
200
+ * 命中来源:**MCP 服务器**的工具 schema。REA MCP(mcp__rea__build_call_path 等
201
+ * 7 个工具)的 `functions` 数组参数,元素 schema 用了
202
+ * `anyOf: [{...}, {type:"null"}]`(可选参数的标准写法)、`oneOf`,还有
203
+ * `{}` 空 schema —— 一份 65 工具的请求里就有 7 个工具中招,整轮 400。
204
+ *
205
+ * 修法(全部是**降级放宽**,方向安全:宁可约束松一点,也不要整轮失败):
206
+ *
207
+ * anyOf / oneOf / allOf → 取一个分支内联(**优先非纯 null 的分支**:
208
+ * `anyOf:[{…},{type:"null"}]` 取前者;反过来写时
209
+ * 照抄第一个会把「可选」压成「只能 null」,那是收紧)
210
+ * not / const / if+then+else → 删除(无等价物;删掉就是放宽)
211
+ * {} → `{type:["object","string","number","boolean","array","null"]}`
212
+ * (六种 JSON 类型全覆盖 = 任意值,与 `{}` 严格等价;
213
+ * **不能**用 `{type:"object"}` —— 那会把「任意」收紧成「只能是对象」)
214
+ *
215
+ * ⚠ 两个容易漏的坑(都踩过):
216
+ * 1. 删除 `not` / `const` 后节点可能变成 `{}` —— 必须在删除后再查一次空对象,
217
+ * 否则改完仍是 400(PR #10 初版就漏在这里,`not`/`const` 两例改写后仍 400)。
218
+ * 2. `items` 子树里 `{}` 和 `true` 都被拒,所以「任意值」只能落成显式 type 数组。
219
+ *
220
+ * ⚠ 只走 `items` 子树:顶层 parameters 的 union 是上游**收**的,动了它
221
+ * 会白白丢掉约束。
222
+ *
223
+ * @returns {boolean} 有没有改到
224
+ */
225
+ export function loosenItemsSchema(node) {
226
+ let changed = false
227
+ // 用 walkSchema 找**每一个** schema 位置上的 items(它只进入真正的 schema
228
+ // 位置:properties 的值、union 的分支、items 的值……不碰 enum/default 的字面值)。
229
+ walkSchema(node, (n) => {
230
+ if (!Object.hasOwn(n, "items")) return false
231
+ const v = n.items
232
+ // tuple 形式(draft-04):`items` 是 schema **数组**。
233
+ // 两站行为不一致 —— 国内站收、**国际站拒**(`[{"type":"string"}] is not of
234
+ // type 'object'`),实测见 `_probe/items-subtree-upstream.mjs`。
235
+ // 数组里的逐位约束在改写里表达不了(`anyOf` 在这层就是 400),
236
+ // 只能整体落成「任意值」—— 纯放宽,不会让原本合法的调用被拒。
237
+ if (Array.isArray(v)) {
238
+ n.items = { type: [...ANY_SCHEMA_TYPE] }
239
+ changed = true
240
+ return false
241
+ }
242
+ if (!v || typeof v !== "object") return false
243
+ if (fixItemsSubtree(v)) changed = true
244
+ return false
245
+ })
246
+ return changed
247
+ }
248
+
249
+ // 空 schema 在 JSON Schema 里等价于 `true` = 接受任意 JSON 值。但上游对
250
+ // `items` 子树里的 `{}` 和 `true` 都拒,只能落成显式的「任意类型」schema。
251
+ const ANY_SCHEMA_TYPE = ["object", "string", "number", "boolean", "array", "null"]
252
+
253
+ /** 把一个空 schema 节点原地改成「任意值」。返回是否真的改到(只对空对象生效)。 */
254
+ function toAnySchema(n) {
255
+ if (Object.keys(n).length > 0) return false
256
+ n.type = [...ANY_SCHEMA_TYPE]
257
+ return true
258
+ }
259
+
260
+ /** 分支是不是只声明了 null —— 取它内联会把「可选」压成「只能 null」(收紧) */
261
+ function isNullOnly(branch) {
262
+ if (!branch || typeof branch !== "object" || Array.isArray(branch)) return false
263
+ if (branch.type === "null") return true
264
+ return Array.isArray(branch.type) && branch.type.length === 1 && branch.type[0] === "null"
265
+ }
266
+
267
+ /**
268
+ * 在 `items` 子树里就地修:union 摊平、const/not/if 删除、空 schema 换形状。
269
+ *
270
+ * 递归同样遵守 schema 位置纪律(见 {@link SCHEMA_MAPS} / {@link SCHEMA_LIST} /
271
+ * {@link SCHEMA_ONE}):`enum` / `default` / `examples` 里的字面值不是 schema,
272
+ * 不能动。
273
+ *
274
+ * @returns {boolean} 有没有改到
275
+ */
276
+ function fixItemsSubtree(n) {
277
+ if (!n || typeof n !== "object" || Array.isArray(n)) return false
278
+ let dirty = false
279
+
280
+ // 空 schema {} → 显式「任意类型」(与 {} 严格等价)
281
+ if (toAnySchema(n)) return true
282
+
283
+ for (const [k, v] of Object.entries(n)) {
284
+ if (k === "anyOf" || k === "oneOf" || k === "allOf") {
285
+ if (!Array.isArray(v) || v.length === 0) {
286
+ delete n[k]
287
+ dirty = true
288
+ continue
289
+ }
290
+ // 优先第一个非纯 null 的分支(顺序反过来时不至于把可选压成「只能 null」)
291
+ const branch =
292
+ v.find((b) => b && typeof b === "object" && !Array.isArray(b) && !isNullOnly(b)) ??
293
+ v.find((b) => b && typeof b === "object" && !Array.isArray(b))
294
+ delete n[k]
295
+ dirty = true
296
+ if (branch) {
297
+ // 分支本身也可能带 union / 空 schema ⇒ 先递归修,再内联
298
+ if (fixItemsSubtree(branch)) dirty = true
299
+ Object.assign(n, branch)
300
+ }
301
+ continue
302
+ }
303
+ if (k === "not" || k === "const" || k === "if" || k === "then" || k === "else") {
304
+ delete n[k]
305
+ dirty = true
306
+ continue
307
+ }
308
+ // 其余按 schema 位置递归
309
+ if (SCHEMA_MAPS.has(k) && !Array.isArray(v)) {
310
+ for (const child of Object.values(v)) if (fixItemsSubtree(child)) dirty = true
311
+ continue
312
+ }
313
+ if (SCHEMA_LIST.has(k) && Array.isArray(v)) {
314
+ for (const child of v) if (fixItemsSubtree(child)) dirty = true
315
+ continue
316
+ }
317
+ if (SCHEMA_ONE.has(k)) {
318
+ if (Array.isArray(v)) {
319
+ for (const child of v) if (fixItemsSubtree(child)) dirty = true
320
+ } else if (fixItemsSubtree(v)) {
321
+ dirty = true
322
+ }
323
+ }
324
+ }
325
+ // 删除 not/const/if… 之后节点可能变空 —— 空 schema 在 items 子树里仍然 400,
326
+ // 必须补成「任意值」。
327
+ if (toAnySchema(n)) dirty = true
328
+ return dirty
329
+ }
330
+
169
331
  /**
170
332
  * 改写发往上游的请求体。**一次 parse,四处改写都在这**。
171
333
  *
@@ -221,6 +383,7 @@ export function rewriteRequestBody(raw) {
221
383
  if (!p || typeof p !== "object") continue
222
384
  if (loosenAdditionalProperties(p)) changed = true
223
385
  if (loosenEmptyItems(p)) changed = true
386
+ if (loosenItemsSchema(p)) changed = true
224
387
  }
225
388
  }
226
389
 
@@ -1,16 +1,26 @@
1
1
  // ② auth.methods[] —— 登录
2
2
  //
3
- // 两条路,按可靠性排:
3
+ // 四条路,按可靠性排:
4
4
  //
5
- // ① 浏览器登录(默认,零输入):插件自己拉起一个能被 CDP 看进去的浏览器,
5
+ // ① 手机号验证码(零浏览器):识图模型过旋转验证 → 发短信 → 用户填码。
6
+ // ② 邮箱验证码:同一条链,只有标识字段和两个端点不同(见下面 makeCodeMethod)。
7
+ // ③ 浏览器登录(默认零输入):插件自己拉起一个能被 CDP 看进去的浏览器,
6
8
  // 用户在官方页里用任何方式登,登录成功那一刻插件把 localStorage 读走。
7
9
  // 拉不起来就降级成「magpie 开系统浏览器 + 用户粘一行 Console」。
8
- // ② 登录态(JWT + 组织码):手填两个字段,兜底用。
10
+ // ④ 登录态(JWT + 组织码):手填两个字段,兜底用。
9
11
  //
10
- // ⚠ 两条铁律:
12
+ // ⚠ 三条铁律:
11
13
  // · authorize() 必须立刻返回 —— 真正等用户的是 callback(),死等会和 magpie 互锁
12
14
  // · opened: true 的语义是「我自己已经开过页面了,你别再开」;
13
15
  // 只返回 url 让它去开的,**不许**传 opened
16
+ // · authorize() 里失败只能抛异常,**不能** return { type: "failed" } ——
17
+ // magpie 把它的返回值当「登录流程对象」,会去调 `.callback()`,于是崩在一句
18
+ // `callback is not a function` 上(实测触发路径:callback 失败 → 用户点「重试」
19
+ // → 重新调 authorize → 又走到失败分支 → 崩)
20
+ //
21
+ // ⚠ 数组顺序 = magpie 登录方式列表里的顺序,所以**按站点分**:
22
+ // 国际站(magicrew-intl)邮箱在前 —— 那边用户大多是邮箱注册的;
23
+ // 国内站手机号在前。
14
24
 
15
25
  import {
16
26
  issueGatewayKey,
@@ -22,7 +32,7 @@ import { inviteToUse, readSession, writeSession, noteLoginFailure, noteOrg, read
22
32
  import { readLocalCreds } from "../local-browser.mjs"
23
33
  import { CLIP_HINT, clipboardText, parseCreds, pollClipboard } from "../clipboard.mjs"
24
34
  import { readVisionModel } from "../vision.mjs"
25
- import { finishSmsLogin, startSmsLogin } from "../headless.mjs"
35
+ import { finishCodeLogin, startCodeLogin } from "../headless.mjs"
26
36
 
27
37
  /**
28
38
  * 账号刚加进来(登录成功)→ 一次性把「首次 AI 工作 / 首个对话」补掉。
@@ -46,6 +56,16 @@ function kickoffFirstTask(c, jwt, org) {
46
56
  * `orgs` 是候选组织列表(无头那条路拿不到「用户当前选中的组织」,只能猜)。
47
57
  * 按顺序用 `fetchPoints` 真验一遍,第一个验得过才算。
48
58
  *
59
+ * 后两个参数只影响**卡片上显示的名字**,但选错会让同一个号在两条登录路上
60
+ * 算出不同的 accountId,magpie 就把同一个号加成两条(真机踩过)。三档优先级:
61
+ *
62
+ * preferredAccountId → who.name(服务端给的手机号/邮箱/昵称)→ fallbackAccountId → 组织码
63
+ *
64
+ * · 手机号验证码那条传 preferred —— 服务端只回打码号(`189****051`),
65
+ * 得由我们自己把它固定成同一个字符串,两条路才一致
66
+ * · 邮箱验证码那条传 fallback —— 邮箱不需要打码;who 成功时优先用 who,
67
+ * 这样和浏览器登录那条路算出来的是同一个
68
+ *
49
69
  * ⚠ 别说这解决了「多组织选哪个」—— **它没解决,只是容错**:
50
70
  * 账号所属的任何一个组织都能通过 `fetchPoints`(只要 JWT 有效),
51
71
  * 所以「第一个验得过的」实际就是「列表第一个」。
@@ -54,7 +74,7 @@ function kickoffFirstTask(c, jwt, org) {
54
74
  * 头都一样),不能拿来判断。⇒ 多组织账号在无头这条路上会落到列表第一个组织,
55
75
  * 这是已知限制,不是 bug。
56
76
  */
57
- async function completeSignIn(c, jwt, org, orgs = [], preferredAccountId = null) {
77
+ async function completeSignIn(c, jwt, org, orgs = [], preferredAccountId = null, fallbackAccountId = null) {
58
78
  const candidates = [org, ...orgs.filter((o) => o && o !== org)]
59
79
  let lastErr = null
60
80
  let picked = null
@@ -85,10 +105,7 @@ async function completeSignIn(c, jwt, org, orgs = [], preferredAccountId = null)
85
105
  // 真机踩过:浏览器登录拿到的是打码手机号 `189****051`(fetchWho 成功,
86
106
  // 服务端只给打码号);无头那条路 fetchWho 挂了(438 字符的 access_token
87
107
  // 配 Bearer 头不通),于是兜底用了组织码 `h97gkbu8bruj` ⇒ 两条并存。
88
- //
89
- // 所以优先用**调用方明确给的** accountId(无头路径里就是它自己算出来的打码手机号),
90
- // `who.name` 只作为补充,组织码是最后一档 —— 它绝不该当账号名。
91
- const accountId = preferredAccountId || who?.name || picked
108
+ const accountId = preferredAccountId || who?.name || fallbackAccountId || picked
92
109
  return {
93
110
  type: "success",
94
111
  access: key.apiKey,
@@ -101,161 +118,235 @@ async function completeSignIn(c, jwt, org, orgs = [], preferredAccountId = null)
101
118
  }
102
119
  }
103
120
 
104
- export function makeMethods(c) {
105
- return [
106
- {
107
- // 手机号 + 短信验证码,**不开浏览器**。
108
- //
109
- // 卡点从来不是协议,是那张「拖动滑块使图片角度为正」的图。现在的做法:
110
- // 用 magpie 设置里用户自己选的识图模型(settings.json 的 vision)
111
- // 判断「主体上方向指向钟面几点」,再换算成拖动角度 —— 实测 120° 的图判出 122°,
112
- // 而服务端容差 ±10°,够用。
113
- //
114
- // 没配识图模型时不在这里瞎猜(猜中概率只有 ~5.6%,会先撞「验证提交过于频繁」),
115
- // 而是让这条方法直接报错并指路 —— 详见下面那段说明。
116
- type: "oauth",
117
- label: `${c.SHORT} 手机号验证码`,
118
- prompts: [
119
- {
120
- type: "text",
121
- key: "phone",
122
- message: "手机号",
123
- placeholder: "13800138000",
124
- validate: (v) =>
125
- /^1\d{10}$/.test(String(v || "").replace(/[\s-]/g, "")) ? undefined : "11 位手机号,只要数字",
126
- },
127
- ],
128
- async authorize(inputs) {
129
- const phone = String(inputs?.phone || "").replace(/[\s-]/g, "")
130
- // ⚠ authorize() 里**不能**返回 { type: "failed" }:magpie 把这里的返回值当作
131
- // 「登录流程对象」,会去调它的 `.callback()` —— 少了这个函数就是一句
132
- // `callback is not a function`,用户只看到莫名其妙的报错。
133
- // 实测触发路径:callback 里失败 → magpie 显示「重试」 → 点重试会**重新调
134
- // authorize** → 这次走到这条失败分支 → 返回没有 callback 的对象 → 崩。
135
- // ⇒ authorize 里失败一律抛异常。
136
- if (!/^1\d{10}$/.test(phone)) throw new Error("手机号不完整")
137
-
138
- const vision = readVisionModel()
139
- const masked = phone.replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2")
140
-
141
- if (!vision) {
142
- // 没有识图模型 → 回退到浏览器:打不开就不硬撑。
143
- //
144
- // 为什么不在这里瞎猜角度:容差 ±10° 意味着瞎猜命中率只有 ~5.6%(20/360),
145
- // 平均要试十几次,而服务端有「验证提交过于频繁」和「验证次数已超限」两道风控 ——
146
- // 等不到猜中,先被限流。所以老老实实让用户转那一下。
147
- let browser = null
148
- let why = ""
149
- try {
150
- browser = await import("../browser-login.mjs")
151
- } catch (e) {
152
- why = `浏览器登录模块加载失败:${String(e?.message ?? e).slice(0, 200)}`
153
- noteLoginFailure(c.id, why)
154
- }
121
+ /** 邮箱的粗校验。别搬 RFC 那个怪物正则 —— 服务端还会再验一次 */
122
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
123
+
124
+ /**
125
+ * 「验证码登录」——手机号与邮箱共用一份实现。
126
+ *
127
+ * 两条链的协议**完全一样**:取图 → 识图判角度 → 过旋转验证 → 发码 → 用户填码 →
128
+ * 换登录态。差异只有三处,而且都已收敛进 captcha.mjs / headless.mjs 按 ident 分派:
129
+ *
130
+ * · 问用户要什么:11 位手机号 / 邮箱
131
+ * · 发码端点:`/v4/user/send-sms` / `/v4/user/send-email`
132
+ * · 换登录态:`/v4/user/login`(验证码字段叫 `code`)/
133
+ * `/v4/user/login-email`(字段叫 `captcha`)
134
+ *
135
+ * 所以这里只写一份、传 kind。哪天被复制成两份,下次改超时或改文案必然只改一份。
136
+ *
137
+ * 卡点从来不是协议,是那张「拖动滑块使图片角度为正」的图:用 magpie 设置里用户
138
+ * 自己选的识图模型(settings.json 的 vision)判「主体上方向指向钟面几点」,再换算
139
+ * 成拖动角度 —— 实测 120° 的图判出 122°,而服务端容差 ±10°,够用。
140
+ *
141
+ * 没配识图模型时**不在这里瞎猜**(猜中概率只有 ~5.6%,会先撞「验证提交过于频繁」),
142
+ * 而是回退到浏览器登录,让用户自己转那一下。
143
+ *
144
+ * @param {object} c 站点闭包(id / SHORT / web / host)
145
+ * @param {"mobile"|"email"} kind
146
+ */
147
+ function makeCodeMethod(c, kind) {
148
+ const isEmail = kind === "email"
149
+ const what = isEmail ? "邮箱" : "手机号"
150
+ /** 官方登录页上那个 tab 的名字 —— 回退到浏览器时要说清楚点哪个 */
151
+ const tab = isEmail ? "邮箱登录(Email)" : "手机登录(Phone)"
155
152
 
156
- const inviteCode = inviteToUse(c.id)
157
- const url = inviteCode
158
- ? `${c.web}/login?invitation=${encodeURIComponent(inviteCode)}`
159
- : `${c.web}/login`
160
- const hostFragment = new URL(c.web).hostname
153
+ /** 用户填的那一项 → 之后每个请求都要带的标识字段 */
154
+ const identOf = (inputs) =>
155
+ isEmail
156
+ ? { email: String(inputs?.email || "").trim() }
157
+ : { mobile: String(inputs?.phone || "").replace(/[\s-]/g, ""), stateCode: "+86" }
161
158
 
162
- const session = browser
163
- ? await browser.launchLoginBrowser(url).catch((e) => {
164
- why = String(e?.message ?? e).slice(0, 300)
165
- noteLoginFailure(c.id, `launch: ${why}`)
166
- return null
167
- })
168
- : null
159
+ /**
160
+ * 给用户看的那个标识。
161
+ * 手机号打码(服务端自己也是这么回的),邮箱原样 —— 邮箱没有打码这回事,
162
+ * 而且它正好是浏览器登录那条路上 `who.name` 会给出的字符串。
163
+ */
164
+ const displayOf = (ident) =>
165
+ isEmail ? ident.email : String(ident.mobile).replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2")
169
166
 
170
- return {
171
- url,
172
- ...(session ? { opened: true } : {}),
173
- instructions: session
174
- ? `没有配置识图模型,那个「拖动滑块使图片角度为正」得你自己转一下 —— 已经用 ${session.name || "浏览器"} 打开了登录页。\n` +
175
- `在里面选「手机登录」,填 ${masked},把图片转正、收到验证码后填进去;登录完成这个窗口会自动接上,不用回来点任何东西。\n` +
176
- `(想全自动的话:magpie 设置 → 识图模型,选一个能看图的,下次就只需要填手机号和验证码。)`
177
- : `没能自己拉起浏览器${why ? `(${why})` : "(本机没找到 Chromium 内核浏览器)"},已改用系统浏览器打开登录页。\n` +
178
- `登录完成后,在页面里按 F12 → Console 粘这一行:\n${CLIP_HINT}`,
179
- method: "auto",
180
- async callback() {
181
- let got = null
182
- if (session && browser) {
183
- got = await browser.waitForCreds(session, hostFragment).catch(() => null)
184
- await browser.closeSession(session).catch(() => {})
185
- }
186
- if (!got && !session) {
187
- got = await pollClipboard({ skip: "" }).catch(() => null)
188
- }
189
- if (!got) {
190
- return {
191
- type: "failed",
192
- error: session
193
- ? "浏览器开着了,但 5 分钟内没在里面读到登录状态 —— 是还没登完,还是登的不是这个站点?"
194
- : "5 分钟内没拿到登录状态。可以改用「登录态(JWT + 组织码)」那条备选。",
195
- }
196
- }
197
- try {
198
- if (!got.org) throw new Error("只拿到 JWT,没有组织码")
199
- return await completeSignIn(c, got.jwt, got.org)
200
- } catch (e) {
201
- return { type: "failed", error: String(e?.message ?? e).slice(0, 500) }
202
- }
203
- },
167
+ const looksInvalid = (ident) => (isEmail ? !EMAIL_RE.test(ident.email) : !/^1\d{10}$/.test(ident.mobile))
168
+
169
+ return {
170
+ type: "oauth",
171
+ label: `${c.SHORT} ${what}验证码`,
172
+ prompts: [
173
+ isEmail
174
+ ? {
175
+ type: "text",
176
+ key: "email",
177
+ message: "邮箱",
178
+ placeholder: "you@example.com",
179
+ validate: (v) =>
180
+ EMAIL_RE.test(String(v || "").trim()) ? undefined : "填一个完整邮箱,例如 you@example.com",
204
181
  }
205
- }
182
+ : {
183
+ type: "text",
184
+ key: "phone",
185
+ message: "手机号",
186
+ placeholder: "13800138000",
187
+ validate: (v) =>
188
+ /^1\d{10}$/.test(String(v || "").replace(/[\s-]/g, "")) ? undefined : "11 位手机号,只要数字",
189
+ },
190
+ ],
191
+ async authorize(inputs) {
192
+ const ident = identOf(inputs)
193
+ // ⚠ 这里是 authorize,失败只能抛异常,不能返回 { type: "failed" }(见文件头铁律)
194
+ if (looksInvalid(ident)) throw new Error(`${what}不完整`)
206
195
 
207
- // 邀请码两条路都要出现:登录页的 URL,以及最终注册时服务端读到的那份。
208
- // ⚠ 无头这条路**没有**「打开带邀请码的页面」这一步,所以邀请码只能靠
209
- // `redirect` 传 —— 网页表单的初始值里 redirect 就是 window.location.href,
210
- // 服务端从它上面取 `?invitation=`。少了它,新注册的号就没有邀请关系。
211
- const inviteCode = inviteToUse(c.id)
212
- const loginUrl = inviteCode
213
- ? `${c.web}/login?invitation=${encodeURIComponent(inviteCode)}`
214
- : `${c.web}/login`
196
+ const shown = displayOf(ident)
197
+
198
+ // 邀请码两条路都要出现:登录页的 URL,以及最终注册时服务端读到的那份。
199
+ // ⚠ 无头这条路**没有**「打开带邀请码的页面」这一步,所以邀请码只能靠
200
+ // `redirect` 传 —— 网页表单的初始值里 redirect 就是 window.location.href,
201
+ // 服务端从它上面取 `?invitation=`。少了它,新注册的号就没有邀请关系。
202
+ const inviteCode = inviteToUse(c.id)
203
+ const loginUrl = inviteCode
204
+ ? `${c.web}/login?invitation=${encodeURIComponent(inviteCode)}`
205
+ : `${c.web}/login`
215
206
 
216
- // 发短信这一步最慢(过验证最多试 3 张图、每张问两遍模型)。
217
- // 但必须等它结束才能告诉用户「验证码已发出」—— 所以这里不甩到 callback 里。
218
- let sent
207
+ /**
208
+ * 需要过图、但用户没配识图模型 → 回退到浏览器,让他自己转那一下。
209
+ *
210
+ * ⚠ 只有**这个号还没注册**时才该走到这里:已注册的号服务端根本不发旋转图,
211
+ * `startCodeLogin` 会直接发码(见 headless.mjs 里那段说明)。
212
+ *
213
+ * 为什么不在这里瞎猜角度:容差 ±10° 意味着瞎猜命中率只有 ~5.6%(20/360),
214
+ * 平均要试十几次,而服务端有「验证提交过于频繁」和「验证次数已超限」两道风控 ——
215
+ * 等不到猜中,先被限流。所以老老实实让用户转那一下。
216
+ */
217
+ async function browserFallback() {
218
+ const url = loginUrl
219
+ const hostFragment = new URL(c.web).hostname
220
+ let browser = null
221
+ let why = ""
219
222
  try {
220
- sent = await startSmsLogin(c, { mobile: phone })
223
+ browser = await import("../browser-login.mjs")
221
224
  } catch (e) {
222
- // 同上:这里也不能 return failed —— 点「重试」会重新走 authorize,
223
- // 返回没有 callback 的对象会让 magpie 崩在 `.callback()`
224
- throw new Error(String(e?.message ?? e).slice(0, 500))
225
+ why = `浏览器登录模块加载失败:${String(e?.message ?? e).slice(0, 200)}`
226
+ noteLoginFailure(c.id, why)
225
227
  }
226
228
 
229
+ const session = browser
230
+ ? await browser.launchLoginBrowser(url).catch((e) => {
231
+ why = String(e?.message ?? e).slice(0, 300)
232
+ noteLoginFailure(c.id, `launch: ${why}`)
233
+ return null
234
+ })
235
+ : null
236
+
227
237
  return {
228
- url: loginUrl,
229
- instructions:
230
- `验证码已发送到 ${masked}。\n` +
231
- `旋转验证由识图模型(${vision})自动通过,试了 ${sent.rounds} 张图。\n` +
232
- `把收到的 6 位验证码填进下面的框就行。`,
233
- method: "code",
234
- // ⚠ 必须是 true。这条链路**全在后台走完**,不需要任何浏览器;
235
- // opened: false 会让 magpie 拿 url 去开**系统默认浏览器** —— 而那个浏览器里
236
- // 往往本来就登着这个站,用户看到的是已登录界面,根本进不了「添加新号」,
237
- // 还会以为插件把他登进了旧账号。这正是浏览器登录那条路修过的坑。
238
- opened: true,
239
- async callback(code) {
240
- const c0 = String(code || "").replace(/\D/g, "")
241
- if (c0.length < 4) return { type: "failed", error: "验证码是 4~8 位数字" }
242
- let creds
243
- try {
244
- creds = await finishSmsLogin(c, { mobile: phone, code: c0, redirect: loginUrl })
245
- } catch (e) {
246
- return { type: "failed", error: String(e?.message ?? e).slice(0, 500) }
238
+ url,
239
+ ...(session ? { opened: true } : {}),
240
+ instructions: session
241
+ ? `没有配置识图模型,那个「拖动滑块使图片角度为正」得你自己转一下 —— 已经用 ${session.name || "浏览器"} 打开了登录页。\n` +
242
+ `在里面选「${tab}」,填 ${shown},把图片转正、收到验证码后填进去;登录完成这个窗口会自动接上,不用回来点任何东西。\n` +
243
+ `(想全自动的话:magpie 设置 → 识图模型,选一个能看图的,下次就只需要填${what}和验证码。)`
244
+ : `没能自己拉起浏览器${why ? `(${why})` : "(本机没找到 Chromium 内核浏览器)"},已改用系统浏览器打开登录页。\n` +
245
+ `登录完成后,在页面里按 F12 → Console 粘这一行:\n${CLIP_HINT}`,
246
+ method: "auto",
247
+ async callback() {
248
+ let got = null
249
+ if (session && browser) {
250
+ got = await browser.waitForCreds(session, hostFragment).catch(() => null)
251
+ await browser.closeSession(session).catch(() => {})
252
+ }
253
+ if (!got && !session) {
254
+ got = await pollClipboard({ skip: "" }).catch(() => null)
255
+ }
256
+ if (!got) {
257
+ return {
258
+ type: "failed",
259
+ error: session
260
+ ? "浏览器开着了,但 5 分钟内没在里面读到登录状态 —— 是还没登完,还是登的不是这个站点?"
261
+ : "5 分钟内没拿到登录状态。可以改用「登录态(JWT + 组织码)」那条备选。",
262
+ }
247
263
  }
248
264
  try {
249
- // 传 masked(打码手机号)当 accountId —— 浏览器登录那条路拿到的也是
250
- // 这个字符串,两边一致才不会把同一个号加成两条
251
- return await completeSignIn(c, creds.jwt, creds.org, creds.orgs, masked)
265
+ if (!got.org) throw new Error("只拿到 JWT,没有组织码")
266
+ return await completeSignIn(c, got.jwt, got.org)
252
267
  } catch (e) {
253
- return { type: "failed", error: `登录成功了,但换 key 失败:${String(e?.message ?? e).slice(0, 300)}` }
268
+ return { type: "failed", error: String(e?.message ?? e).slice(0, 500) }
254
269
  }
255
270
  },
256
271
  }
257
- },
272
+ }
273
+
274
+ // 发码这一步最慢(要给新号过验证时最多试 4 张图、每张问两遍模型)。
275
+ // 但必须等它结束才能告诉用户「验证码已发出」—— 所以这里不甩到 callback 里。
276
+ let sent
277
+ try {
278
+ sent = await startCodeLogin(c, ident)
279
+ } catch (e) {
280
+ if (e?.code === "no-vision-model") {
281
+ // ⚡ 只有这一种情况才回退到浏览器:这个号**还没注册**(服务端给了旋转图),
282
+ // 而用户没配识图模型。已注册的号走不到这里 —— 服务端不出图。
283
+ return browserFallback()
284
+ }
285
+ // 其余一律抛:authorize 里 return failed 会让 magpie 崩在 `.callback()`
286
+ throw new Error(String(e?.message ?? e).slice(0, 500))
287
+ }
288
+
289
+ return {
290
+ url: loginUrl,
291
+ instructions: sent.skippedCaptcha
292
+ ? `验证码已发送到 ${shown}。\n` +
293
+ `这个号服务端那边已经注册过,旋转验证直接放行 —— 这一环跳过了,识图模型这次也用不上。\n` +
294
+ `把收到的 6 位验证码填进下面的框就行。`
295
+ : `验证码已发送到 ${shown}。\n` +
296
+ `旋转验证由识图模型(${readVisionModel()})自动通过,试了 ${sent.rounds} 张图。\n` +
297
+ `把收到的 6 位验证码填进下面的框就行。`,
298
+ method: "code",
299
+ // ⚠ 必须是 true。这条链路**全在后台走完**,不需要任何浏览器;
300
+ // opened: false 会让 magpie 拿 url 去开**系统默认浏览器** —— 而那个浏览器里
301
+ // 往往本来就登着这个站,用户看到的是已登录界面,根本进不了「添加新号」,
302
+ // 还会以为插件把他登进了旧账号。这正是浏览器登录那条路修过的坑。
303
+ opened: true,
304
+ async callback(code) {
305
+ const c0 = String(code || "").replace(/\D/g, "")
306
+ if (c0.length < 4) return { type: "failed", error: "验证码是 4~8 位数字" }
307
+ let creds
308
+ try {
309
+ creds = await finishCodeLogin(c, { ...ident, code: c0, redirect: loginUrl })
310
+ } catch (e) {
311
+ return { type: "failed", error: String(e?.message ?? e).slice(0, 500) }
312
+ }
313
+ try {
314
+ // 手机号:把打码号钉成 accountId(浏览器登录那条路拿到的也是这个字符串);
315
+ // 邮箱:优先让 who 决定(那才是浏览器登录会给出的一致值),who 挂了才退回邮箱
316
+ return await completeSignIn(
317
+ c,
318
+ creds.jwt,
319
+ creds.org,
320
+ creds.orgs,
321
+ isEmail ? null : shown,
322
+ isEmail ? ident.email : null,
323
+ )
324
+ } catch (e) {
325
+ return { type: "failed", error: `登录成功了,但换 key 失败:${String(e?.message ?? e).slice(0, 300)}` }
326
+ }
327
+ },
328
+ }
258
329
  },
330
+ }
331
+ }
332
+
333
+ export function makeMethods(c) {
334
+ // 顺序 = magpie 登录方式列表里的顺序。
335
+ //
336
+ // ⚠ **邮箱验证码只有国际站有**,国内站不给。实测两件事:
337
+ // ① www.letsmagic.cn/login 的登录方式只有 微信 / 苹果 / 密码 / 手机 / 私有部署,
338
+ // **没有邮箱**(国际站 www.magicrew.ai/login 才有 `switch-to-email`);
339
+ // ② 同一个发信请求,国内站 `/v4/user/send-email` 回 `2000 服务异常,请联系管理员`,
340
+ // 国际站回 `1000 请求成功`。
341
+ // 国内站既然没有邮箱入口,也就不存在邮箱注册的账号 —— 把这条方法摆出来,
342
+ // 只会让用户填完邮箱撞一个看不懂的错。
343
+ const codeMethods =
344
+ c.id === "magicrew-intl"
345
+ ? [makeCodeMethod(c, "email"), makeCodeMethod(c, "mobile")]
346
+ : [makeCodeMethod(c, "mobile")]
347
+
348
+ return [
349
+ ...codeMethods,
259
350
 
260
351
  {
261
352
  type: "oauth",