magicrew-auth 0.3.6 → 0.3.8
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/CHANGELOG.md +306 -176
- package/README.md +10 -2
- package/docs/internals.md +868 -802
- package/package.json +1 -1
- package/src/api.mjs +449 -409
- package/src/hooks/checkin.mjs +7 -0
- package/src/hooks/methods.mjs +243 -191
- package/src/keys.mjs +12 -1
- package/src/local-browser.mjs +163 -0
- package/src/store.mjs +239 -183
- package/src/tasks.mjs +81 -63
package/src/api.mjs
CHANGED
|
@@ -1,409 +1,449 @@
|
|
|
1
|
-
// Magicrew / 超级麦吉 的 HTTP 层
|
|
2
|
-
//
|
|
3
|
-
// 一个产品两个站,后端不同、模型也不同:
|
|
4
|
-
//
|
|
5
|
-
// 国内 超级麦吉 www.letsmagic.cn i-magic-service.letsmagic.cn
|
|
6
|
-
// 国际 MagiCrew www.magicrew.ai 待定(客户端是同一个包,API host 还没摸到)
|
|
7
|
-
//
|
|
8
|
-
// 每个站点两个 base:
|
|
9
|
-
// 业务 API <host>/api/v1/... 要网页 JWT
|
|
10
|
-
// 模型网关 <host>/v1/... OpenAI 兼容,要 mgw_ api_key
|
|
11
|
-
//
|
|
12
|
-
// 三层凭证,有效期完全不同:
|
|
13
|
-
// ① 网页登录 JWT 30 天
|
|
14
|
-
// ② mgw_ api_key 2 小时
|
|
15
|
-
// ③ refresh_token 7 天(PUT 一次轮换一次,一次性)
|
|
16
|
-
//
|
|
17
|
-
// ⇒ 插件只需要 ① 在登录时拿一次,之后 30 天全靠 ③ 自动滚。
|
|
18
|
-
|
|
19
|
-
const UA =
|
|
20
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"
|
|
21
|
-
|
|
22
|
-
// 一个包 = 两个 provider。
|
|
23
|
-
//
|
|
24
|
-
// 每个站点的 `id` 就是它在 magpie 里的**供应商 id** —— 不是内部代号。
|
|
25
|
-
// 所以两个站会各自占一行(供应商列表、用量概览、模型目录全分开),
|
|
26
|
-
// 和 @magpie-community/opencode-workbuddy-auth 承载 WorkBuddy / WorkBuddy AI、
|
|
27
|
-
// opencode-qoder-auth 承载 Qoder / Qoder CN / QwenWork 是同一个做法:
|
|
28
|
-
// export const MagicrewAuthPlugin = makePlugin(SITES.cn)
|
|
29
|
-
// export const MagicrewIntlAuthPlugin = makePlugin(SITES.intl)
|
|
30
|
-
//
|
|
31
|
-
// ⚠ `cn` 的 id 必须保持 `magicrew` —— 已装的用户靠这个 id 认领登录态。
|
|
32
|
-
export const SITES = {
|
|
33
|
-
cn: {
|
|
34
|
-
id: "magicrew",
|
|
35
|
-
label: "国内 · 超级麦吉",
|
|
36
|
-
web: "https://www.letsmagic.cn",
|
|
37
|
-
host: "https://i-magic-service.letsmagic.cn",
|
|
38
|
-
},
|
|
39
|
-
intl: {
|
|
40
|
-
id: "magicrew-intl",
|
|
41
|
-
label: "国际 · MagiCrew",
|
|
42
|
-
web: "https://www.magicrew.ai",
|
|
43
|
-
// 实测(2026-10-27):来源是 www.magicrew.ai 的运行时配置注入页
|
|
44
|
-
// GET https://www.magicrew.ai/workspace → HTML 里内嵌 window 配置
|
|
45
|
-
// "MAGIC_SERVICE_BASE_URL":"https://magic-service.magicrew.ai"
|
|
46
|
-
// "MAGIC_APP_ENV":"international-prod"
|
|
47
|
-
//
|
|
48
|
-
// ⚠ api.magicrew.ai 不是这套 API —— 它是 MAGIC_GATEWAY_ADDRESS(teamshare 网关),
|
|
49
|
-
// 对 /v1/models、/api/v1/* 一律回 "not match rule path"。
|
|
50
|
-
//
|
|
51
|
-
// 该网关与国内 i-magic-service.letsmagic.cn 逐字符同构(同一个 Go 服务):
|
|
52
|
-
// GET / → hello, magic-service!
|
|
53
|
-
// GET /v1/models → {"code":403,"message":"token无效"}
|
|
54
|
-
// GET /api/v1/flows/models → {"code":2179,"message":"auth_token 不存在"}
|
|
55
|
-
host: "https://magic-service.magicrew.ai",
|
|
56
|
-
// 国际站 /v1/chat/completions **强制**要求 organization-code:
|
|
57
|
-
// 不带 → HTTP 500 "Internal Server Error."(22 字节裸文本,不是 JSON)
|
|
58
|
-
// 带上 → 200 正常 chat.completion
|
|
59
|
-
// 国内站 /v1/* 不需要(README 里那套一直是两条 base 分开用的)。
|
|
60
|
-
needsOrgOnGateway: true,
|
|
61
|
-
},
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function site(id) {
|
|
65
|
-
return SITES[id] ?? SITES.cn
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
class MagicrewError extends Error {
|
|
69
|
-
constructor(message, { status, code, site: s } = {}) {
|
|
70
|
-
super(message)
|
|
71
|
-
this.name = "MagicrewError"
|
|
72
|
-
this.status = status
|
|
73
|
-
this.code = code
|
|
74
|
-
this.site = s
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function headersFor(token, org, host) {
|
|
79
|
-
const h = {
|
|
80
|
-
authorization: `Bearer ${token}`,
|
|
81
|
-
"content-type": "application/json",
|
|
82
|
-
"user-agent": UA,
|
|
83
|
-
accept: "application/json",
|
|
84
|
-
// 网关会看 Origin,缺了偶发 403
|
|
85
|
-
origin: host ? new URL(host).origin : "https://www.letsmagic.cn",
|
|
86
|
-
}
|
|
87
|
-
if (org) h["organization-code"] = org
|
|
88
|
-
return h
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
async function read(res) {
|
|
92
|
-
const text = await res.text()
|
|
93
|
-
let body
|
|
94
|
-
try {
|
|
95
|
-
body = text ? JSON.parse(text) : {}
|
|
96
|
-
} catch {
|
|
97
|
-
body = { message: text.slice(0, 200) }
|
|
98
|
-
}
|
|
99
|
-
if (!res.ok) {
|
|
100
|
-
throw new MagicrewError(body?.message || `HTTP ${res.status}`, { status: res.status, code: body?.code })
|
|
101
|
-
}
|
|
102
|
-
// magic 的业务错误码:1000 = ok,其它都是错
|
|
103
|
-
if (typeof body?.code === "number" && body.code !== 1000) {
|
|
104
|
-
throw new MagicrewError(body.message || `code ${body.code}`, { status: res.status, code: body.code })
|
|
105
|
-
}
|
|
106
|
-
return body
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// ── ① 登录 ───────────────────────────────────────────────────────────
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* 手机号 + 密码登录。
|
|
113
|
-
*
|
|
114
|
-
* 实测(2026-10-27 国内站):type 只认 "phone_password";"phone_code" 直接 500。
|
|
115
|
-
* 账号不存在时返回 {"code":33002,"message":"账号不存在"}。
|
|
116
|
-
*
|
|
117
|
-
* 国际站登录页有 Google / Password / Email / Private Service 四种,password 这条大概率同构。
|
|
118
|
-
*/
|
|
119
|
-
export async function login({
|
|
120
|
-
phone,
|
|
121
|
-
password,
|
|
122
|
-
stateCode = "+86",
|
|
123
|
-
organizationCode = "",
|
|
124
|
-
host = SITES.cn.host,
|
|
125
|
-
inviteCode = "",
|
|
126
|
-
}) {
|
|
127
|
-
const res = await fetch(`${host}/api/v1/sessions`, {
|
|
128
|
-
method: "POST",
|
|
129
|
-
headers: headersFor("", organizationCode, host),
|
|
130
|
-
body: JSON.stringify({
|
|
131
|
-
type: "phone_password",
|
|
132
|
-
phone: String(phone || "").replace(/[^\d]/g, ""),
|
|
133
|
-
password: password || "",
|
|
134
|
-
state_code: stateCode || "+86",
|
|
135
|
-
organization_code: organizationCode || "",
|
|
136
|
-
email: "",
|
|
137
|
-
redirect: "",
|
|
138
|
-
// 注册邀请码。
|
|
139
|
-
//
|
|
140
|
-
// 字段名来自上游前端 dtyq/magic 的 `types/login.ts`(`invite_code?: string`),
|
|
141
|
-
// 消费时机写在 `LoginService.ts` 的注释里:
|
|
142
|
-
// // PS: 改到在无组织下创建组织的时候再消费邀请码
|
|
143
|
-
// // values.invite_code = consumeInvitationCookie()
|
|
144
|
-
// ⇒ 不是登录那一下生效,是「该账号还没有组织、这次登录顺手建个人组织」时生效。
|
|
145
|
-
// 已经是老账号、组织早就有了,传了也不会再记账。
|
|
146
|
-
//
|
|
147
|
-
// 不传就是空串,服务端忽略(登录本身不受影响)。
|
|
148
|
-
invite_code: inviteCode || "",
|
|
149
|
-
}),
|
|
150
|
-
})
|
|
151
|
-
const body = await read(res)
|
|
152
|
-
const d = body?.data ?? body
|
|
153
|
-
|
|
154
|
-
// 字段名官方文档没有;三种写法都兜住,别赌一个
|
|
155
|
-
const token = d?.token || d?.access_token || d?.accessToken || d?.jwt || ""
|
|
156
|
-
if (!token) throw new MagicrewError("登录成功但没拿到 token", { code: body?.code })
|
|
157
|
-
|
|
158
|
-
return {
|
|
159
|
-
token,
|
|
160
|
-
organizationCode: d?.organization_code || d?.organizationCode || organizationCode || "",
|
|
161
|
-
raw: d,
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// ── ② 模型网关的 key ─────────────────────────────────────────────────
|
|
166
|
-
|
|
167
|
-
/** 用登录 JWT 签发一把 mgw_ key(2 小时)。 */
|
|
168
|
-
export async function issueGatewayKey(token, org, host = SITES.cn.host) {
|
|
169
|
-
const res = await fetch(`${host}/api/v1/model-gateway/tokens`, {
|
|
170
|
-
method: "POST",
|
|
171
|
-
headers: headersFor(token, org, host),
|
|
172
|
-
body: "{}",
|
|
173
|
-
})
|
|
174
|
-
const body = await read(res)
|
|
175
|
-
const d = body?.data ?? {}
|
|
176
|
-
if (!d.api_key) throw new MagicrewError("签发密钥失败:没有 api_key")
|
|
177
|
-
return {
|
|
178
|
-
apiKey: d.api_key,
|
|
179
|
-
refreshToken: d.refresh_token,
|
|
180
|
-
apiKeyExpiresAt: d.api_key_expires_at,
|
|
181
|
-
refreshTokenExpiresAt: d.refresh_token_expires_at,
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* 用 refresh_token 换新的 api_key。
|
|
187
|
-
* 不需要登录态;refresh_token 会**轮换**(一次性的),必须串行调用。
|
|
188
|
-
*/
|
|
189
|
-
export async function refreshGatewayKey(refreshToken, host = SITES.cn.host) {
|
|
190
|
-
const res = await fetch(`${host}/api/v1/model-gateway/tokens`, {
|
|
191
|
-
method: "PUT",
|
|
192
|
-
headers: { "content-type": "application/json", "user-agent": UA },
|
|
193
|
-
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
194
|
-
})
|
|
195
|
-
const body = await read(res)
|
|
196
|
-
const d = body?.data ?? {}
|
|
197
|
-
if (!d.api_key) throw new MagicrewError("续期失败:没有 api_key")
|
|
198
|
-
return {
|
|
199
|
-
apiKey: d.api_key,
|
|
200
|
-
refreshToken: d.refresh_token,
|
|
201
|
-
apiKeyExpiresAt: d.api_key_expires_at,
|
|
202
|
-
refreshTokenExpiresAt: d.refresh_token_expires_at,
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// ── ③ 模型 ───────────────────────────────────────────────────────────
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* 账号**当前能选**的语言模型。
|
|
210
|
-
*
|
|
211
|
-
* 注意别用 /v1/models —— 国内站它返回 44 个,含 VIP 锁定、生图、embedding,
|
|
212
|
-
* 塞进 magpie 会让用户选到根本调不动的模型。可选模型只有这里这 27 个。
|
|
213
|
-
*
|
|
214
|
-
* 实测返回字段:value / label / icon / tags / configs / vision
|
|
215
|
-
*/
|
|
216
|
-
export async function fetchModels(token, org, host = SITES.cn.host) {
|
|
217
|
-
const res = await fetch(`${host}/api/v1/flows/models`, { headers: headersFor(token, org, host) })
|
|
218
|
-
const body = await read(res)
|
|
219
|
-
const list = body?.data?.models ?? []
|
|
220
|
-
return list.map((m) => ({
|
|
221
|
-
id: m.value,
|
|
222
|
-
name: (m.label || m.value).trim(),
|
|
223
|
-
vision: !!m.vision,
|
|
224
|
-
}))
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/** 网关上的全量模型 id(含生图/视频/embedding),用来做分类。 */
|
|
228
|
-
export async function fetchAllModels(apiKey, host = SITES.cn.host) {
|
|
229
|
-
const res = await fetch(`${host}/v1/models`, {
|
|
230
|
-
headers: { authorization: `Bearer ${apiKey}`, "user-agent": UA },
|
|
231
|
-
})
|
|
232
|
-
const body = await read(res)
|
|
233
|
-
return (body?.data ?? []).map((m) => m.id)
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* 每个模型的计费类型与积分单价。
|
|
238
|
-
*
|
|
239
|
-
* billing_type: TextTokens / Tokens → 语言或向量
|
|
240
|
-
* Times / ImageCount / Seedream5ProImage → 生图
|
|
241
|
-
* input_points / output_points 是**每百万 token 的积分**。
|
|
242
|
-
*/
|
|
243
|
-
export async function fetchModelPoints(token, org, modelIds, host = SITES.cn.host) {
|
|
244
|
-
const res = await fetch(`${host}/api/v1/products/model-points`, {
|
|
245
|
-
method: "POST",
|
|
246
|
-
headers: headersFor(token, org, host),
|
|
247
|
-
body: JSON.stringify({ model_ids: modelIds }),
|
|
248
|
-
})
|
|
249
|
-
const body = await read(res)
|
|
250
|
-
const out = {}
|
|
251
|
-
for (const m of body?.data ?? []) {
|
|
252
|
-
out[m.model_id] = {
|
|
253
|
-
billingType: m.billing_type,
|
|
254
|
-
inputPoints: m.input_points,
|
|
255
|
-
outputPoints: m.output_points,
|
|
256
|
-
kind: classify(m.billing_type, m.model_id),
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
return out
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const EMBEDDING_RE = /embedding|dmeta/i
|
|
263
|
-
const IMAGE_RE = /image|seedream|aes_general/i
|
|
264
|
-
|
|
265
|
-
function classify(billingType, modelId) {
|
|
266
|
-
if (EMBEDDING_RE.test(modelId)) return "embedding"
|
|
267
|
-
if (billingType === "Times" || billingType === "ImageCount" || /Image$/i.test(billingType || "")) return "image"
|
|
268
|
-
if (IMAGE_RE.test(modelId)) return "image"
|
|
269
|
-
if (billingType === "TextTokens" || billingType === "Tokens") return "language"
|
|
270
|
-
return "other"
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// ── ③b 新手任务(onboarding)──────────────────────────────────────────
|
|
274
|
-
//
|
|
275
|
-
// 这条是前端 chunk `onboarding-CZBI0pQt.js` 里挖出来的,只有一行:
|
|
276
|
-
// GET /api/v1/onboarding/tasks
|
|
277
|
-
// PUT /api/v1/onboarding/tasks/{code}/completion
|
|
278
|
-
//
|
|
279
|
-
// 8 项任务里 7 项可以纯 HTTP 完成(PUT 即到账),只有 `first_ai_work`
|
|
280
|
-
// 返回 9303「该新人引导任务不支持手动完成」—— 那项是真的要跑一次 AI 任务。
|
|
281
|
-
|
|
282
|
-
export async function fetchOnboardingTasks(token, org, host = SITES.cn.host) {
|
|
283
|
-
const res = await fetch(`${host}/api/v1/onboarding/tasks`, { headers: headersFor(token, org, host) })
|
|
284
|
-
const body = await read(res)
|
|
285
|
-
return (body?.data?.tasks ?? []).map((t) => ({
|
|
286
|
-
code: t.code,
|
|
287
|
-
title: t.title,
|
|
288
|
-
points: Number(t.points ?? 0),
|
|
289
|
-
status: t.status,
|
|
290
|
-
rewardStatus: t.reward_status,
|
|
291
|
-
completedAt: t.completed_at,
|
|
292
|
-
}))
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* 标记一项任务完成。成功返回 { points },失败抛出带 code 的错误。
|
|
297
|
-
* 已知失败码:9303 = 该任务不支持手动完成。
|
|
298
|
-
*/
|
|
299
|
-
export async function completeOnboardingTask(token, org, code, host = SITES.cn.host) {
|
|
300
|
-
const res = await fetch(`${host}/api/v1/onboarding/tasks/${encodeURIComponent(code)}/completion`, {
|
|
301
|
-
method: "PUT",
|
|
302
|
-
headers: headersFor(token, org, host),
|
|
303
|
-
body: "{}",
|
|
304
|
-
})
|
|
305
|
-
const body = await read(res)
|
|
306
|
-
return { points: Number(body?.data?.points ?? 0) }
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// ──
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
//
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
export
|
|
1
|
+
// Magicrew / 超级麦吉 的 HTTP 层
|
|
2
|
+
//
|
|
3
|
+
// 一个产品两个站,后端不同、模型也不同:
|
|
4
|
+
//
|
|
5
|
+
// 国内 超级麦吉 www.letsmagic.cn i-magic-service.letsmagic.cn
|
|
6
|
+
// 国际 MagiCrew www.magicrew.ai 待定(客户端是同一个包,API host 还没摸到)
|
|
7
|
+
//
|
|
8
|
+
// 每个站点两个 base:
|
|
9
|
+
// 业务 API <host>/api/v1/... 要网页 JWT
|
|
10
|
+
// 模型网关 <host>/v1/... OpenAI 兼容,要 mgw_ api_key
|
|
11
|
+
//
|
|
12
|
+
// 三层凭证,有效期完全不同:
|
|
13
|
+
// ① 网页登录 JWT 30 天
|
|
14
|
+
// ② mgw_ api_key 2 小时
|
|
15
|
+
// ③ refresh_token 7 天(PUT 一次轮换一次,一次性)
|
|
16
|
+
//
|
|
17
|
+
// ⇒ 插件只需要 ① 在登录时拿一次,之后 30 天全靠 ③ 自动滚。
|
|
18
|
+
|
|
19
|
+
const UA =
|
|
20
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"
|
|
21
|
+
|
|
22
|
+
// 一个包 = 两个 provider。
|
|
23
|
+
//
|
|
24
|
+
// 每个站点的 `id` 就是它在 magpie 里的**供应商 id** —— 不是内部代号。
|
|
25
|
+
// 所以两个站会各自占一行(供应商列表、用量概览、模型目录全分开),
|
|
26
|
+
// 和 @magpie-community/opencode-workbuddy-auth 承载 WorkBuddy / WorkBuddy AI、
|
|
27
|
+
// opencode-qoder-auth 承载 Qoder / Qoder CN / QwenWork 是同一个做法:
|
|
28
|
+
// export const MagicrewAuthPlugin = makePlugin(SITES.cn)
|
|
29
|
+
// export const MagicrewIntlAuthPlugin = makePlugin(SITES.intl)
|
|
30
|
+
//
|
|
31
|
+
// ⚠ `cn` 的 id 必须保持 `magicrew` —— 已装的用户靠这个 id 认领登录态。
|
|
32
|
+
export const SITES = {
|
|
33
|
+
cn: {
|
|
34
|
+
id: "magicrew",
|
|
35
|
+
label: "国内 · 超级麦吉",
|
|
36
|
+
web: "https://www.letsmagic.cn",
|
|
37
|
+
host: "https://i-magic-service.letsmagic.cn",
|
|
38
|
+
},
|
|
39
|
+
intl: {
|
|
40
|
+
id: "magicrew-intl",
|
|
41
|
+
label: "国际 · MagiCrew",
|
|
42
|
+
web: "https://www.magicrew.ai",
|
|
43
|
+
// 实测(2026-10-27):来源是 www.magicrew.ai 的运行时配置注入页
|
|
44
|
+
// GET https://www.magicrew.ai/workspace → HTML 里内嵌 window 配置
|
|
45
|
+
// "MAGIC_SERVICE_BASE_URL":"https://magic-service.magicrew.ai"
|
|
46
|
+
// "MAGIC_APP_ENV":"international-prod"
|
|
47
|
+
//
|
|
48
|
+
// ⚠ api.magicrew.ai 不是这套 API —— 它是 MAGIC_GATEWAY_ADDRESS(teamshare 网关),
|
|
49
|
+
// 对 /v1/models、/api/v1/* 一律回 "not match rule path"。
|
|
50
|
+
//
|
|
51
|
+
// 该网关与国内 i-magic-service.letsmagic.cn 逐字符同构(同一个 Go 服务):
|
|
52
|
+
// GET / → hello, magic-service!
|
|
53
|
+
// GET /v1/models → {"code":403,"message":"token无效"}
|
|
54
|
+
// GET /api/v1/flows/models → {"code":2179,"message":"auth_token 不存在"}
|
|
55
|
+
host: "https://magic-service.magicrew.ai",
|
|
56
|
+
// 国际站 /v1/chat/completions **强制**要求 organization-code:
|
|
57
|
+
// 不带 → HTTP 500 "Internal Server Error."(22 字节裸文本,不是 JSON)
|
|
58
|
+
// 带上 → 200 正常 chat.completion
|
|
59
|
+
// 国内站 /v1/* 不需要(README 里那套一直是两条 base 分开用的)。
|
|
60
|
+
needsOrgOnGateway: true,
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function site(id) {
|
|
65
|
+
return SITES[id] ?? SITES.cn
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
class MagicrewError extends Error {
|
|
69
|
+
constructor(message, { status, code, site: s } = {}) {
|
|
70
|
+
super(message)
|
|
71
|
+
this.name = "MagicrewError"
|
|
72
|
+
this.status = status
|
|
73
|
+
this.code = code
|
|
74
|
+
this.site = s
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function headersFor(token, org, host) {
|
|
79
|
+
const h = {
|
|
80
|
+
authorization: `Bearer ${token}`,
|
|
81
|
+
"content-type": "application/json",
|
|
82
|
+
"user-agent": UA,
|
|
83
|
+
accept: "application/json",
|
|
84
|
+
// 网关会看 Origin,缺了偶发 403
|
|
85
|
+
origin: host ? new URL(host).origin : "https://www.letsmagic.cn",
|
|
86
|
+
}
|
|
87
|
+
if (org) h["organization-code"] = org
|
|
88
|
+
return h
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function read(res) {
|
|
92
|
+
const text = await res.text()
|
|
93
|
+
let body
|
|
94
|
+
try {
|
|
95
|
+
body = text ? JSON.parse(text) : {}
|
|
96
|
+
} catch {
|
|
97
|
+
body = { message: text.slice(0, 200) }
|
|
98
|
+
}
|
|
99
|
+
if (!res.ok) {
|
|
100
|
+
throw new MagicrewError(body?.message || `HTTP ${res.status}`, { status: res.status, code: body?.code })
|
|
101
|
+
}
|
|
102
|
+
// magic 的业务错误码:1000 = ok,其它都是错
|
|
103
|
+
if (typeof body?.code === "number" && body.code !== 1000) {
|
|
104
|
+
throw new MagicrewError(body.message || `code ${body.code}`, { status: res.status, code: body.code })
|
|
105
|
+
}
|
|
106
|
+
return body
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── ① 登录 ───────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 手机号 + 密码登录。
|
|
113
|
+
*
|
|
114
|
+
* 实测(2026-10-27 国内站):type 只认 "phone_password";"phone_code" 直接 500。
|
|
115
|
+
* 账号不存在时返回 {"code":33002,"message":"账号不存在"}。
|
|
116
|
+
*
|
|
117
|
+
* 国际站登录页有 Google / Password / Email / Private Service 四种,password 这条大概率同构。
|
|
118
|
+
*/
|
|
119
|
+
export async function login({
|
|
120
|
+
phone,
|
|
121
|
+
password,
|
|
122
|
+
stateCode = "+86",
|
|
123
|
+
organizationCode = "",
|
|
124
|
+
host = SITES.cn.host,
|
|
125
|
+
inviteCode = "",
|
|
126
|
+
}) {
|
|
127
|
+
const res = await fetch(`${host}/api/v1/sessions`, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: headersFor("", organizationCode, host),
|
|
130
|
+
body: JSON.stringify({
|
|
131
|
+
type: "phone_password",
|
|
132
|
+
phone: String(phone || "").replace(/[^\d]/g, ""),
|
|
133
|
+
password: password || "",
|
|
134
|
+
state_code: stateCode || "+86",
|
|
135
|
+
organization_code: organizationCode || "",
|
|
136
|
+
email: "",
|
|
137
|
+
redirect: "",
|
|
138
|
+
// 注册邀请码。
|
|
139
|
+
//
|
|
140
|
+
// 字段名来自上游前端 dtyq/magic 的 `types/login.ts`(`invite_code?: string`),
|
|
141
|
+
// 消费时机写在 `LoginService.ts` 的注释里:
|
|
142
|
+
// // PS: 改到在无组织下创建组织的时候再消费邀请码
|
|
143
|
+
// // values.invite_code = consumeInvitationCookie()
|
|
144
|
+
// ⇒ 不是登录那一下生效,是「该账号还没有组织、这次登录顺手建个人组织」时生效。
|
|
145
|
+
// 已经是老账号、组织早就有了,传了也不会再记账。
|
|
146
|
+
//
|
|
147
|
+
// 不传就是空串,服务端忽略(登录本身不受影响)。
|
|
148
|
+
invite_code: inviteCode || "",
|
|
149
|
+
}),
|
|
150
|
+
})
|
|
151
|
+
const body = await read(res)
|
|
152
|
+
const d = body?.data ?? body
|
|
153
|
+
|
|
154
|
+
// 字段名官方文档没有;三种写法都兜住,别赌一个
|
|
155
|
+
const token = d?.token || d?.access_token || d?.accessToken || d?.jwt || ""
|
|
156
|
+
if (!token) throw new MagicrewError("登录成功但没拿到 token", { code: body?.code })
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
token,
|
|
160
|
+
organizationCode: d?.organization_code || d?.organizationCode || organizationCode || "",
|
|
161
|
+
raw: d,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── ② 模型网关的 key ─────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
/** 用登录 JWT 签发一把 mgw_ key(2 小时)。 */
|
|
168
|
+
export async function issueGatewayKey(token, org, host = SITES.cn.host) {
|
|
169
|
+
const res = await fetch(`${host}/api/v1/model-gateway/tokens`, {
|
|
170
|
+
method: "POST",
|
|
171
|
+
headers: headersFor(token, org, host),
|
|
172
|
+
body: "{}",
|
|
173
|
+
})
|
|
174
|
+
const body = await read(res)
|
|
175
|
+
const d = body?.data ?? {}
|
|
176
|
+
if (!d.api_key) throw new MagicrewError("签发密钥失败:没有 api_key")
|
|
177
|
+
return {
|
|
178
|
+
apiKey: d.api_key,
|
|
179
|
+
refreshToken: d.refresh_token,
|
|
180
|
+
apiKeyExpiresAt: d.api_key_expires_at,
|
|
181
|
+
refreshTokenExpiresAt: d.refresh_token_expires_at,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 用 refresh_token 换新的 api_key。
|
|
187
|
+
* 不需要登录态;refresh_token 会**轮换**(一次性的),必须串行调用。
|
|
188
|
+
*/
|
|
189
|
+
export async function refreshGatewayKey(refreshToken, host = SITES.cn.host) {
|
|
190
|
+
const res = await fetch(`${host}/api/v1/model-gateway/tokens`, {
|
|
191
|
+
method: "PUT",
|
|
192
|
+
headers: { "content-type": "application/json", "user-agent": UA },
|
|
193
|
+
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
194
|
+
})
|
|
195
|
+
const body = await read(res)
|
|
196
|
+
const d = body?.data ?? {}
|
|
197
|
+
if (!d.api_key) throw new MagicrewError("续期失败:没有 api_key")
|
|
198
|
+
return {
|
|
199
|
+
apiKey: d.api_key,
|
|
200
|
+
refreshToken: d.refresh_token,
|
|
201
|
+
apiKeyExpiresAt: d.api_key_expires_at,
|
|
202
|
+
refreshTokenExpiresAt: d.refresh_token_expires_at,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── ③ 模型 ───────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 账号**当前能选**的语言模型。
|
|
210
|
+
*
|
|
211
|
+
* 注意别用 /v1/models —— 国内站它返回 44 个,含 VIP 锁定、生图、embedding,
|
|
212
|
+
* 塞进 magpie 会让用户选到根本调不动的模型。可选模型只有这里这 27 个。
|
|
213
|
+
*
|
|
214
|
+
* 实测返回字段:value / label / icon / tags / configs / vision
|
|
215
|
+
*/
|
|
216
|
+
export async function fetchModels(token, org, host = SITES.cn.host) {
|
|
217
|
+
const res = await fetch(`${host}/api/v1/flows/models`, { headers: headersFor(token, org, host) })
|
|
218
|
+
const body = await read(res)
|
|
219
|
+
const list = body?.data?.models ?? []
|
|
220
|
+
return list.map((m) => ({
|
|
221
|
+
id: m.value,
|
|
222
|
+
name: (m.label || m.value).trim(),
|
|
223
|
+
vision: !!m.vision,
|
|
224
|
+
}))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** 网关上的全量模型 id(含生图/视频/embedding),用来做分类。 */
|
|
228
|
+
export async function fetchAllModels(apiKey, host = SITES.cn.host) {
|
|
229
|
+
const res = await fetch(`${host}/v1/models`, {
|
|
230
|
+
headers: { authorization: `Bearer ${apiKey}`, "user-agent": UA },
|
|
231
|
+
})
|
|
232
|
+
const body = await read(res)
|
|
233
|
+
return (body?.data ?? []).map((m) => m.id)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 每个模型的计费类型与积分单价。
|
|
238
|
+
*
|
|
239
|
+
* billing_type: TextTokens / Tokens → 语言或向量
|
|
240
|
+
* Times / ImageCount / Seedream5ProImage → 生图
|
|
241
|
+
* input_points / output_points 是**每百万 token 的积分**。
|
|
242
|
+
*/
|
|
243
|
+
export async function fetchModelPoints(token, org, modelIds, host = SITES.cn.host) {
|
|
244
|
+
const res = await fetch(`${host}/api/v1/products/model-points`, {
|
|
245
|
+
method: "POST",
|
|
246
|
+
headers: headersFor(token, org, host),
|
|
247
|
+
body: JSON.stringify({ model_ids: modelIds }),
|
|
248
|
+
})
|
|
249
|
+
const body = await read(res)
|
|
250
|
+
const out = {}
|
|
251
|
+
for (const m of body?.data ?? []) {
|
|
252
|
+
out[m.model_id] = {
|
|
253
|
+
billingType: m.billing_type,
|
|
254
|
+
inputPoints: m.input_points,
|
|
255
|
+
outputPoints: m.output_points,
|
|
256
|
+
kind: classify(m.billing_type, m.model_id),
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return out
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const EMBEDDING_RE = /embedding|dmeta/i
|
|
263
|
+
const IMAGE_RE = /image|seedream|aes_general/i
|
|
264
|
+
|
|
265
|
+
function classify(billingType, modelId) {
|
|
266
|
+
if (EMBEDDING_RE.test(modelId)) return "embedding"
|
|
267
|
+
if (billingType === "Times" || billingType === "ImageCount" || /Image$/i.test(billingType || "")) return "image"
|
|
268
|
+
if (IMAGE_RE.test(modelId)) return "image"
|
|
269
|
+
if (billingType === "TextTokens" || billingType === "Tokens") return "language"
|
|
270
|
+
return "other"
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── ③b 新手任务(onboarding)──────────────────────────────────────────
|
|
274
|
+
//
|
|
275
|
+
// 这条是前端 chunk `onboarding-CZBI0pQt.js` 里挖出来的,只有一行:
|
|
276
|
+
// GET /api/v1/onboarding/tasks
|
|
277
|
+
// PUT /api/v1/onboarding/tasks/{code}/completion
|
|
278
|
+
//
|
|
279
|
+
// 8 项任务里 7 项可以纯 HTTP 完成(PUT 即到账),只有 `first_ai_work`
|
|
280
|
+
// 返回 9303「该新人引导任务不支持手动完成」—— 那项是真的要跑一次 AI 任务。
|
|
281
|
+
|
|
282
|
+
export async function fetchOnboardingTasks(token, org, host = SITES.cn.host) {
|
|
283
|
+
const res = await fetch(`${host}/api/v1/onboarding/tasks`, { headers: headersFor(token, org, host) })
|
|
284
|
+
const body = await read(res)
|
|
285
|
+
return (body?.data?.tasks ?? []).map((t) => ({
|
|
286
|
+
code: t.code,
|
|
287
|
+
title: t.title,
|
|
288
|
+
points: Number(t.points ?? 0),
|
|
289
|
+
status: t.status,
|
|
290
|
+
rewardStatus: t.reward_status,
|
|
291
|
+
completedAt: t.completed_at,
|
|
292
|
+
}))
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 标记一项任务完成。成功返回 { points },失败抛出带 code 的错误。
|
|
297
|
+
* 已知失败码:9303 = 该任务不支持手动完成。
|
|
298
|
+
*/
|
|
299
|
+
export async function completeOnboardingTask(token, org, code, host = SITES.cn.host) {
|
|
300
|
+
const res = await fetch(`${host}/api/v1/onboarding/tasks/${encodeURIComponent(code)}/completion`, {
|
|
301
|
+
method: "PUT",
|
|
302
|
+
headers: headersFor(token, org, host),
|
|
303
|
+
body: "{}",
|
|
304
|
+
})
|
|
305
|
+
const body = await read(res)
|
|
306
|
+
return { points: Number(body?.data?.points ?? 0) }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── ③c 每日登录积分(每日签到)────────────────────────────────────────
|
|
310
|
+
//
|
|
311
|
+
// POST /api/v1/login-gift,**无请求体**。这条是 2026-10-27 从网页抓包里挖出来的:
|
|
312
|
+
//
|
|
313
|
+
// POST /api/v1/login-gift
|
|
314
|
+
// → {"code":1000,"data":{"granted":true,"point_amount":50,
|
|
315
|
+
// "expire_type":1,"message":"成功领取每日登录积分"}}
|
|
316
|
+
//
|
|
317
|
+
// 关键性质(同日实测,两个站点都验过):
|
|
318
|
+
//
|
|
319
|
+
// · 幂等:当天已领过 → granted:false, point_amount:0, "今日已领取登录积分"
|
|
320
|
+
// HTTP 仍是 200,不是错误 —— 所以可以每天无脑调用
|
|
321
|
+
// · 两站同构:国际站 +50/天,国内站 **+200/天**,路径、方法、返回结构完全一样
|
|
322
|
+
// · 奖励进的是当天 23:59:59 过期的额度包(expire_type:1),不累积
|
|
323
|
+
//
|
|
324
|
+
// ⚠ 这条和「新手福利」(onboarding/tasks)是两套完全独立的机制。
|
|
325
|
+
// 国际站的 onboarding 接口 participant_type 恒为 null、tasks 恒为空数组,
|
|
326
|
+
// 靠它永远领不到每日登录积分 —— 早前版本就是这么漏的。
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* 领取当日登录积分。已领取不是错误,靠 granted 字段区分。
|
|
330
|
+
*
|
|
331
|
+
* @returns {{granted:boolean, points:number, message:string}}
|
|
332
|
+
*/
|
|
333
|
+
export async function claimLoginGift(token, org, host = SITES.cn.host) {
|
|
334
|
+
const res = await fetch(`${host}/api/v1/login-gift`, {
|
|
335
|
+
method: "POST",
|
|
336
|
+
headers: headersFor(token, org, host),
|
|
337
|
+
body: "",
|
|
338
|
+
})
|
|
339
|
+
const body = await read(res)
|
|
340
|
+
const d = body?.data ?? {}
|
|
341
|
+
return {
|
|
342
|
+
granted: Boolean(d.granted),
|
|
343
|
+
points: Number(d.point_amount ?? 0),
|
|
344
|
+
message: String(d.message ?? ""),
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ── ④ 积分与任务 ─────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* 积分余额。
|
|
352
|
+
*
|
|
353
|
+
* expiring_quota_details 每一段是一个会过期的额度包,列表顺序 = 先过期的先扣。
|
|
354
|
+
*/
|
|
355
|
+
export async function fetchPoints(token, org, host = SITES.cn.host) {
|
|
356
|
+
const res = await fetch(`${host}/api/v1/quota/points/total`, { headers: headersFor(token, org, host) })
|
|
357
|
+
const body = await read(res)
|
|
358
|
+
const d = body?.data ?? {}
|
|
359
|
+
return {
|
|
360
|
+
total: Number(d.total_points ?? 0),
|
|
361
|
+
expiring: (d.expiring_quota_details ?? []).map((q) => ({
|
|
362
|
+
points: Number(q.remaining_points ?? 0),
|
|
363
|
+
expiresAt: q.expires_at,
|
|
364
|
+
})),
|
|
365
|
+
nextGrant: d.next_cycle_grant ?? null,
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** 积分流水(倒序)。签到和任务做完后可以用来确认到账。 */
|
|
370
|
+
export async function fetchPointRecords(token, org, { page = 1, pageSize = 20, host = SITES.cn.host } = {}) {
|
|
371
|
+
// ⚠ 是 POST 不是 GET(GET 回 405 Allow: POST)
|
|
372
|
+
const res = await fetch(`${host}/api/v1/quota/points/records/queries`, {
|
|
373
|
+
method: "POST",
|
|
374
|
+
headers: headersFor(token, org, host),
|
|
375
|
+
body: JSON.stringify({ page: String(page), page_size: String(pageSize) }),
|
|
376
|
+
})
|
|
377
|
+
const body = await read(res)
|
|
378
|
+
return body?.data?.list ?? body?.data ?? []
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ── ④b 自己的邀请码 ──────────────────────────────────────────────────
|
|
382
|
+
//
|
|
383
|
+
// 「Free Points Program」那套分享激励。页面上看到的现成链接长这样:
|
|
384
|
+
//
|
|
385
|
+
// https://www.magicrew.ai/?invitation=IV75F0ZKVKXG
|
|
386
|
+
// https://www.letsmagic.cn/?invitation=<code>
|
|
387
|
+
//
|
|
388
|
+
// 奖励口径两站不同(来自上游前端 constants,2026-10-27 读的):
|
|
389
|
+
//
|
|
390
|
+
// 每次注册 每次订阅 邀请注册封顶 总额封顶
|
|
391
|
+
// 国内 1,000 3,000 60,000 300,000
|
|
392
|
+
// 国际 200 500 10,000 30,000
|
|
393
|
+
//
|
|
394
|
+
// 接口(2026-10-27 实测,从客户端 Cache/Cache_Data/data_1 里读出来的):
|
|
395
|
+
//
|
|
396
|
+
// GET /api/v1/invitations/info → { invite_code, invite_count, invited_users }
|
|
397
|
+
// GET /api/v1/invitations/activity → 活动配置
|
|
398
|
+
// GET /api/v1/invitations/rewards/total-points → 已赚积分
|
|
399
|
+
//
|
|
400
|
+
// ⚠ 早前这里写着「这三个接口只认客户端那把 JWT(438 字符),网页版那把 354 字符的
|
|
401
|
+
// 调过去回 2155 用户不存在」—— **2026-10-27 复核,这条已经不成立**:
|
|
402
|
+
// 四个账号(两站各两个)全是 354 字符的网页 JWT、cus.device_id 全为空,
|
|
403
|
+
// `/api/v1/invitations/info` 全部返回 200 并正常给出 invite_code。
|
|
404
|
+
// 上游放宽了这个限制。所以「用我自己的邀请码」不再受 JWT 来源限制,
|
|
405
|
+
// 只要手里有登录态就能换成自己的码。
|
|
406
|
+
export const INVITE_CODE_PATH = "/api/v1/invitations/info"
|
|
407
|
+
|
|
408
|
+
/** 拿不到就抛错 —— 调用方要的是「真实结果」,不是静默降级 */
|
|
409
|
+
export async function fetchOwnInviteCode(token, org, host = SITES.cn.host) {
|
|
410
|
+
const res = await fetch(`${host}${INVITE_CODE_PATH}`, { headers: headersFor(token, org, host) })
|
|
411
|
+
const body = await read(res)
|
|
412
|
+
const d = body?.data ?? {}
|
|
413
|
+
const code = d.invite_code || d.inviteCode || d.code || d.invitation_code || ""
|
|
414
|
+
if (!code) throw new MagicrewError("接口通了但返回里没有 invite_code")
|
|
415
|
+
return {
|
|
416
|
+
code,
|
|
417
|
+
count: Number(d.invite_count ?? 0),
|
|
418
|
+
invited: Array.isArray(d.invited_users) ? d.invited_users.length : 0,
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ── ④c 这个登录态是谁 ────────────────────────────────────────────────
|
|
423
|
+
//
|
|
424
|
+
// 卡片上原来显示组织码(orgcode67890 / orgcode12345),那是随机串,用户认不出是谁。
|
|
425
|
+
//
|
|
426
|
+
// 优先级:**手机号 → 邮箱 → 昵称 → 真名**。
|
|
427
|
+
//
|
|
428
|
+
// ⚠ 昵称排在后面是有原因的:那是用户自己填的,新账号可以不填、也可以随便填
|
|
429
|
+
// (作者自己的账号填的是 "MYCF711",跟他是谁毫无关系)。手机号是登录凭据本身,
|
|
430
|
+
// 改不了、也漏不掉。
|
|
431
|
+
//
|
|
432
|
+
// ⚠ 服务端把手机号打码了(实测 `138****8000`,中间四位星号),邮箱常常是 null。
|
|
433
|
+
// 打码归打码,它仍然是唯一稳定的账号标识 —— 至少能看出是哪个号。
|
|
434
|
+
//
|
|
435
|
+
// 接口来自客户端缓存里出现过的清单(docs/internals.md 第九节的办法找到的)。
|
|
436
|
+
export async function fetchWho(token, org, host = SITES.cn.host) {
|
|
437
|
+
const res = await fetch(`${host}/api/v1/contact/accounts/me/users`, { headers: headersFor(token, org, host) })
|
|
438
|
+
const body = await read(res)
|
|
439
|
+
const u = body?.data?.items?.[0] ?? body?.data ?? {}
|
|
440
|
+
const name = String(u.phone || u.email || u.nickname || u.real_name || "").trim()
|
|
441
|
+
return {
|
|
442
|
+
name,
|
|
443
|
+
nickname: String(u.nickname || "").trim(),
|
|
444
|
+
phone: String(u.phone || ""),
|
|
445
|
+
email: String(u.email || ""),
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export { MagicrewError, headersFor as _headers }
|