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.
- package/AGENTS.md +4 -1
- package/CHANGELOG.md +280 -0
- package/README.md +133 -59
- package/docs/internals.md +122 -0
- package/package.json +1 -1
- package/src/captcha.mjs +146 -43
- package/src/headless.mjs +79 -29
- package/src/hooks/loader.mjs +163 -0
- package/src/hooks/methods.mjs +238 -147
- package/src/vision.mjs +83 -25
package/src/hooks/loader.mjs
CHANGED
|
@@ -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
|
|
package/src/hooks/methods.mjs
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
// ② auth.methods[] —— 登录
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// 四条路,按可靠性排:
|
|
4
4
|
//
|
|
5
|
-
// ①
|
|
5
|
+
// ① 手机号验证码(零浏览器):识图模型过旋转验证 → 发短信 → 用户填码。
|
|
6
|
+
// ② 邮箱验证码:同一条链,只有标识字段和两个端点不同(见下面 makeCodeMethod)。
|
|
7
|
+
// ③ 浏览器登录(默认零输入):插件自己拉起一个能被 CDP 看进去的浏览器,
|
|
6
8
|
// 用户在官方页里用任何方式登,登录成功那一刻插件把 localStorage 读走。
|
|
7
9
|
// 拉不起来就降级成「magpie 开系统浏览器 + 用户粘一行 Console」。
|
|
8
|
-
//
|
|
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 {
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
223
|
+
browser = await import("../browser-login.mjs")
|
|
221
224
|
} catch (e) {
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
|
|
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
|
-
|
|
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:
|
|
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",
|