dsh-multi2api 0.1.4 → 0.1.6
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/README.md +28 -2
- package/lib/client.js +130 -34
- package/lib/index.js +66 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,11 +22,18 @@
|
|
|
22
22
|
|
|
23
23
|
## 安装
|
|
24
24
|
|
|
25
|
+
这是一个 DSH 插件,用 DSH 自带的插件命令装:
|
|
26
|
+
|
|
25
27
|
```bash
|
|
26
|
-
|
|
28
|
+
dsh plugin --profile web install dsh-multi2api
|
|
27
29
|
```
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
把 `web` 换成你要装的 profile 名字(比如 `tui`、`headless`)。装完重启 DSH 就能用。
|
|
32
|
+
|
|
33
|
+
也可以直接在 DSH 网页端左侧栏的 **插件(Plugins)** 页面里,点「添加插件」填包名 `dsh-multi2api` 安装。
|
|
34
|
+
|
|
35
|
+
> `npm install dsh-multi2api` 也能下载到这个包,但它只是把包装进普通 `node_modules`,
|
|
36
|
+
> 不会挂进你的 profile,还得自己改 `dsh.profile.bundles`,所以推荐用上面的命令。
|
|
30
37
|
|
|
31
38
|
## 使用
|
|
32
39
|
|
|
@@ -59,6 +66,25 @@ npm install dsh-multi2api
|
|
|
59
66
|
- 如果之前手动跑过 `multi2api`,把它的 `auths/*.json` 复制到本插件的数据目录,
|
|
60
67
|
或者在面板里用「导入账号文件」导入即可。
|
|
61
68
|
|
|
69
|
+
## 更新日志
|
|
70
|
+
|
|
71
|
+
### 0.1.6
|
|
72
|
+
|
|
73
|
+
修了三个会让你用不下去的毛病:
|
|
74
|
+
|
|
75
|
+
- **桌面端(DSH Desktop / Electron)加不上账号**:管理接口的写操作守卫原来要求请求必须带
|
|
76
|
+
`Origin` 头,而桌面端经 Electron 网络层转发出来的请求不带这个头,于是加号、签到、删号、
|
|
77
|
+
保存设置、重启**全部 403 `origin-required`**。现在去掉了这条硬性要求,改成
|
|
78
|
+
「Host 必须是回环地址」+「Origin 一旦出现就必须也是回环」+「必须 `Content-Type: application/json`」,
|
|
79
|
+
防跨站伪造和防 DNS rebinding 的能力不变。
|
|
80
|
+
- **点了「添加账号」两秒后自己中断**:上游在"还没登录完"时返回的是
|
|
81
|
+
`HTTP 200 + code=11217 (login ing...)`,旧代码把它当成致命错误抛出去,直接掐断了整个登录流程,
|
|
82
|
+
账号永远加不上(那个可以手动复制的登录链接也跟着一起消失了)。现在未完成会被正确识别为
|
|
83
|
+
"继续等待"。
|
|
84
|
+
- **改成手动确认登录**:不再后台自动轮询(它容易判断错)。现在点「添加账号」会**立刻显示完整
|
|
85
|
+
登录链接并提供「复制链接」按钮**——浏览器拉不起来也能自己粘到浏览器里打开。你在浏览器里
|
|
86
|
+
登录完成后,回到面板点「我已登录完成」即可。没检测到会提示"再点一次",不会把面板关掉。
|
|
87
|
+
|
|
62
88
|
## 许可
|
|
63
89
|
|
|
64
90
|
MIT
|
package/lib/client.js
CHANGED
|
@@ -37,11 +37,18 @@ window.__ModuleLoader__.load({
|
|
|
37
37
|
confirmDelete: "Delete this account? The service will restart.",
|
|
38
38
|
model: "Model",
|
|
39
39
|
login: "Add account",
|
|
40
|
-
loginHint: "
|
|
40
|
+
loginHint: "Open the link below in a browser to sign in. If the browser did not open, copy the link below and open it yourself.",
|
|
41
41
|
openLogin: "Open sign-in page",
|
|
42
42
|
waitingLogin: "Waiting for sign-in…",
|
|
43
43
|
loginDone: "Account added.",
|
|
44
44
|
cancel: "Cancel",
|
|
45
|
+
copyLink: "Copy link",
|
|
46
|
+
copied: "Copied",
|
|
47
|
+
openFailed: "Browser did not open? Copy the link below and open it yourself.",
|
|
48
|
+
confirmLogin: "I have signed in",
|
|
49
|
+
confirmHint: "Finish signing in in the browser, then come back here and click the button below.",
|
|
50
|
+
confirming: "Checking…",
|
|
51
|
+
loginNotYet: "Not confirmed yet. Make sure you finished signing in, wait a few seconds and click again.",
|
|
45
52
|
importAccount: "Import account files",
|
|
46
53
|
importHint:
|
|
47
54
|
"Pick one or several credential JSON files exported from another machine, or paste the JSON below. Each file adds one account.",
|
|
@@ -116,11 +123,18 @@ window.__ModuleLoader__.load({
|
|
|
116
123
|
confirmDelete: "确定删除这个账号吗?服务会重启。",
|
|
117
124
|
model: "模型",
|
|
118
125
|
login: "添加账号",
|
|
119
|
-
loginHint: "
|
|
126
|
+
loginHint: "用浏览器打开下面的登录链接完成登录。浏览器没自动打开,就复制下面的链接自己粘到浏览器里打开。",
|
|
120
127
|
openLogin: "打开登录页",
|
|
121
128
|
waitingLogin: "等待登录中…",
|
|
122
129
|
loginDone: "账号已添加。",
|
|
123
130
|
cancel: "取消",
|
|
131
|
+
copyLink: "复制链接",
|
|
132
|
+
copied: "已复制",
|
|
133
|
+
openFailed: "浏览器没能自动打开?复制下面的链接,自己粘到浏览器地址栏里打开就能登录。",
|
|
134
|
+
confirmLogin: "我已登录完成",
|
|
135
|
+
confirmHint: "在浏览器里登录成功后,回到这里点下面的按钮。",
|
|
136
|
+
confirming: "正在确认…",
|
|
137
|
+
loginNotYet: "还没检测到登录完成。确认一下浏览器里已经登录成功,等几秒再点一次。",
|
|
124
138
|
importAccount: "导入账号文件",
|
|
125
139
|
importHint:
|
|
126
140
|
"选一个或多个从别的机器导出的凭证 JSON 文件,每个文件添加一个账号;也可以直接把内容粘在下面。",
|
|
@@ -186,6 +200,33 @@ window.__ModuleLoader__.load({
|
|
|
186
200
|
});
|
|
187
201
|
}
|
|
188
202
|
|
|
203
|
+
// 复制到剪贴板:优先用 Clipboard API,失败就退回 textarea + execCommand
|
|
204
|
+
// (桌面端 WebView 里 Clipboard API 有时没有权限)。
|
|
205
|
+
async function copyText(text) {
|
|
206
|
+
try {
|
|
207
|
+
if (navigator?.clipboard?.writeText) {
|
|
208
|
+
await navigator.clipboard.writeText(text);
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
} catch {
|
|
212
|
+
// 落到下面的兜底
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const area = document.createElement("textarea");
|
|
216
|
+
area.value = text;
|
|
217
|
+
area.setAttribute("readonly", "");
|
|
218
|
+
area.style.position = "fixed";
|
|
219
|
+
area.style.top = "-1000px";
|
|
220
|
+
document.body.appendChild(area);
|
|
221
|
+
area.select();
|
|
222
|
+
const ok = document.execCommand("copy");
|
|
223
|
+
document.body.removeChild(area);
|
|
224
|
+
return ok;
|
|
225
|
+
} catch {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
189
230
|
function formatCredits(value) {
|
|
190
231
|
const number = Number(value);
|
|
191
232
|
if (!Number.isFinite(number)) return "—";
|
|
@@ -317,6 +358,8 @@ window.__ModuleLoader__.load({
|
|
|
317
358
|
const [busy, setBusy] = React.useState("");
|
|
318
359
|
const [notice, setNotice] = React.useState("");
|
|
319
360
|
const [login, setLogin] = React.useState(undefined);
|
|
361
|
+
const [copied, setCopied] = React.useState(false);
|
|
362
|
+
const [openBlocked, setOpenBlocked] = React.useState(false);
|
|
320
363
|
const [importOpen, setImportOpen] = React.useState(false);
|
|
321
364
|
const [importText, setImportText] = React.useState("");
|
|
322
365
|
const [pasteOpen, setPasteOpen] = React.useState(false);
|
|
@@ -355,36 +398,37 @@ window.__ModuleLoader__.load({
|
|
|
355
398
|
};
|
|
356
399
|
}, [load]);
|
|
357
400
|
|
|
358
|
-
//
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
401
|
+
// 登录不做后台自动轮询。用户在浏览器里登录成功后自己点「我已登录完成」,
|
|
402
|
+
// 由这里主动请求一次确认(服务端内部只做一轮很短的有限重试)。
|
|
403
|
+
// 好处:什么时候算"好了"由用户说了算,不会出现"它自己检测不明白"的情况。
|
|
404
|
+
const confirmLogin = async () => {
|
|
405
|
+
if (login === undefined || login.state === undefined) return;
|
|
406
|
+
setBusy("confirm");
|
|
407
|
+
setError("");
|
|
408
|
+
setNotice("");
|
|
409
|
+
try {
|
|
410
|
+
const body = await post("/login/confirm", { state: login.state });
|
|
411
|
+
if (body?.done === true) {
|
|
412
|
+
setLogin(undefined);
|
|
413
|
+
if (body.ok === true) {
|
|
414
|
+
setNotice(t("loginDone"));
|
|
415
|
+
await load();
|
|
416
|
+
} else {
|
|
417
|
+
setError(String(body?.error ?? t("saveFailed")));
|
|
375
418
|
}
|
|
376
|
-
|
|
377
|
-
if (!stopped) setError(String(pollError.message ?? pollError));
|
|
419
|
+
return;
|
|
378
420
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
}
|
|
387
|
-
|
|
421
|
+
if (body?.ok !== true) {
|
|
422
|
+
setError(
|
|
423
|
+
body?.retry === true ? t("loginNotYet") : String(body?.error ?? t("saveFailed")),
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
} catch (confirmError) {
|
|
427
|
+
setError(String(confirmError.message ?? confirmError));
|
|
428
|
+
} finally {
|
|
429
|
+
setBusy("");
|
|
430
|
+
}
|
|
431
|
+
};
|
|
388
432
|
|
|
389
433
|
const run = async (name, action) => {
|
|
390
434
|
setBusy(name);
|
|
@@ -507,7 +551,15 @@ window.__ModuleLoader__.load({
|
|
|
507
551
|
const body = await post("/login/start", {});
|
|
508
552
|
if (body?.ok !== true) throw new Error(body?.error ?? t("saveFailed"));
|
|
509
553
|
setLogin({ state: body.state, authUrl: body.authUrl });
|
|
510
|
-
|
|
554
|
+
setCopied(false);
|
|
555
|
+
// 自动拉起浏览器;拉不起来就把网址留在下面让用户自己复制。
|
|
556
|
+
let opened;
|
|
557
|
+
try {
|
|
558
|
+
opened = window.open(body.authUrl, "_blank", "noopener,noreferrer");
|
|
559
|
+
} catch {
|
|
560
|
+
opened = undefined;
|
|
561
|
+
}
|
|
562
|
+
setOpenBlocked(!opened);
|
|
511
563
|
});
|
|
512
564
|
},
|
|
513
565
|
},
|
|
@@ -539,15 +591,58 @@ window.__ModuleLoader__.load({
|
|
|
539
591
|
},
|
|
540
592
|
t("openLogin"),
|
|
541
593
|
),
|
|
542
|
-
|
|
594
|
+
openBlocked
|
|
595
|
+
? React.createElement("p", { className: "m2a-hint" }, t("openFailed"))
|
|
596
|
+
: null,
|
|
597
|
+
React.createElement("textarea", {
|
|
598
|
+
className: "m2a-url",
|
|
599
|
+
readOnly: true,
|
|
600
|
+
rows: 3,
|
|
601
|
+
value: login.authUrl,
|
|
602
|
+
onFocus: (event) => {
|
|
603
|
+
event.target.select();
|
|
604
|
+
},
|
|
605
|
+
}),
|
|
543
606
|
React.createElement(
|
|
544
607
|
Button,
|
|
545
608
|
{
|
|
546
609
|
onClick: () => {
|
|
547
|
-
|
|
610
|
+
void (async () => {
|
|
611
|
+
const done = await copyText(login.authUrl);
|
|
612
|
+
setCopied(done);
|
|
613
|
+
window.setTimeout(() => setCopied(false), 2000);
|
|
614
|
+
})();
|
|
548
615
|
},
|
|
549
616
|
},
|
|
550
|
-
t("
|
|
617
|
+
copied ? t("copied") : t("copyLink"),
|
|
618
|
+
),
|
|
619
|
+
React.createElement("p", { className: "m2a-hint" }, t("confirmHint")),
|
|
620
|
+
React.createElement(
|
|
621
|
+
"div",
|
|
622
|
+
{ className: "m2a-card-actions" },
|
|
623
|
+
React.createElement(
|
|
624
|
+
Button,
|
|
625
|
+
{
|
|
626
|
+
variant: "primary",
|
|
627
|
+
disabled: busy === "confirm",
|
|
628
|
+
onClick: () => {
|
|
629
|
+
void confirmLogin();
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
busy === "confirm" ? t("confirming") : t("confirmLogin"),
|
|
633
|
+
),
|
|
634
|
+
React.createElement(
|
|
635
|
+
Button,
|
|
636
|
+
{
|
|
637
|
+
disabled: busy === "confirm",
|
|
638
|
+
onClick: () => {
|
|
639
|
+
setLogin(undefined);
|
|
640
|
+
setOpenBlocked(false);
|
|
641
|
+
setError("");
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
t("cancel"),
|
|
645
|
+
),
|
|
551
646
|
),
|
|
552
647
|
)
|
|
553
648
|
: null,
|
|
@@ -897,6 +992,7 @@ window.__ModuleLoader__.load({
|
|
|
897
992
|
".m2a-hint{color:var(--dsw-alias-label-tertiary);font-size:13px;margin:0}",
|
|
898
993
|
".m2a-login{display:flex;flex-direction:column;gap:8px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:10px}",
|
|
899
994
|
".m2a-link{color:var(--dsw-alias-brand-primary);font-size:13px}",
|
|
995
|
+
".m2a-url{width:100%;box-sizing:border-box;font-family:var(--dsw-font-mono,ui-monospace,monospace);font-size:11px;line-height:1.5;resize:vertical;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l1);border-radius:6px;padding:6px 8px;word-break:break-all}",
|
|
900
996
|
".m2a-section{display:flex;flex-direction:column;gap:8px;border-top:1px solid var(--dsw-alias-border-l1);padding-top:10px}",
|
|
901
997
|
".m2a-subtitle{margin:0;font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}",
|
|
902
998
|
".m2a-empty{color:var(--dsw-alias-label-tertiary);font-size:13px;margin:0}",
|
package/lib/index.js
CHANGED
|
@@ -68,6 +68,12 @@ const WB = {
|
|
|
68
68
|
pollIntervalMs: 2000,
|
|
69
69
|
};
|
|
70
70
|
|
|
71
|
+
// 一次「添加账号」从点击到完成的总时限。
|
|
72
|
+
const LOGIN_TTL_MS = 10 * 60 * 1000;
|
|
73
|
+
// 用户点「我已登录完成」后,服务端做一轮**有界**重试的次数(间隔用 WB.pollIntervalMs)。
|
|
74
|
+
// 刻意不做无限自动轮询:后台瞎猜不如让用户点一下。
|
|
75
|
+
const LOGIN_CONFIRM_ATTEMPTS = 6;
|
|
76
|
+
|
|
71
77
|
const NO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
72
78
|
|
|
73
79
|
const COMPAT = {
|
|
@@ -192,16 +198,21 @@ async function readBody(req, limit = MODELS_BODY_LIMIT) {
|
|
|
192
198
|
return JSON.parse(text);
|
|
193
199
|
}
|
|
194
200
|
|
|
195
|
-
//
|
|
201
|
+
// 写操作防护分两层,都在这里把关:
|
|
202
|
+
// 1. 防 DNS rebinding —— Host 必须是回环地址(见 loopbackRequest);
|
|
203
|
+
// 2. 防跨站伪造 —— Origin 一旦出现,就必须也是回环地址。
|
|
204
|
+
//
|
|
205
|
+
// 注意:**不能**要求 Origin 必须存在。DSH 桌面端(Electron)把渲染进程的请求经由
|
|
206
|
+
// Electron 网络层转发,转发时不带 Origin 头,一旦这里强校验,桌面端整个管理面板
|
|
207
|
+
// 的写操作(加号 / 签到 / 删号 / 保存设置 / 重启)会全部 403 origin-required。
|
|
208
|
+
// 少了 Origin 校验并不等于失去 CSRF 防护:本接口要求 Content-Type: application/json,
|
|
209
|
+
// 跨源 POST 会触发 CORS 预检而被浏览器拦下;跨站表单只能发 text/plain、
|
|
210
|
+
// application/x-www-form-urlencoded 或 multipart/form-data,会被下面的 415 挡掉。
|
|
196
211
|
function checkLoopbackPost(req, res) {
|
|
197
212
|
if (!loopbackRequest(req)) {
|
|
198
213
|
json(res, 403, { error: "request-not-trusted" });
|
|
199
214
|
return false;
|
|
200
215
|
}
|
|
201
|
-
if (typeof req.headers.origin !== "string") {
|
|
202
|
-
json(res, 403, { error: "origin-required" });
|
|
203
|
-
return false;
|
|
204
|
-
}
|
|
205
216
|
const contentType = String(req.headers["content-type"] ?? "");
|
|
206
217
|
if (!contentType.toLowerCase().startsWith("application/json")) {
|
|
207
218
|
json(res, 415, { error: "content-type must be application/json" });
|
|
@@ -328,27 +339,31 @@ async function workbuddyLoginStart() {
|
|
|
328
339
|
return { state, authUrl };
|
|
329
340
|
}
|
|
330
341
|
|
|
331
|
-
// 第 2 步:轮询换 token。返回 {accessToken, refreshToken, expiresIn, domain}
|
|
342
|
+
// 第 2 步:轮询换 token。返回 {accessToken, refreshToken, expiresIn, domain};
|
|
343
|
+
// **登录尚未完成时返回 undefined**(上游此时回 HTTP 200 + code=11217 "login ing...",
|
|
344
|
+
// 或 4xx),这属于"还没好",必须继续轮询,绝不能被当成失败。
|
|
345
|
+
// 只有网络错误和 5xx 才算致命 —— 与 Go 侧 cmd/login/workbuddy.go 的 wbPollToken 判定一致。
|
|
332
346
|
async function workbuddyLoginPoll(state) {
|
|
333
347
|
const account = { domain: "www.codebuddy.cn", accessToken: "", uid: "", enterpriseId: "" };
|
|
334
348
|
const url = `${WB.chatBaseCN}/v2/plugin/auth/token?state=${encodeURIComponent(state)}`;
|
|
335
|
-
|
|
336
|
-
|
|
349
|
+
let response;
|
|
350
|
+
try {
|
|
351
|
+
response = await fetch(url, { headers: commonHeaders(account), signal: timeoutSignal(30000) });
|
|
352
|
+
} catch (error) {
|
|
353
|
+
throw new Error(`token endpoint unreachable: ${messageOf(error)}`);
|
|
354
|
+
}
|
|
337
355
|
if (response.status === 0 || response.status >= 500) {
|
|
338
356
|
throw new Error(`token endpoint error: upstream ${response.status}`);
|
|
339
357
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
}
|
|
358
|
+
const text = await response.text();
|
|
359
|
+
if (response.status >= 400) return undefined;
|
|
343
360
|
let envelope;
|
|
344
361
|
try {
|
|
345
362
|
envelope = text.length > 0 ? JSON.parse(text) : {};
|
|
346
363
|
} catch {
|
|
347
|
-
|
|
348
|
-
}
|
|
349
|
-
if (envelope?.code !== undefined && Number(envelope.code) !== 0) {
|
|
350
|
-
throw new Error(`code=${envelope.code} msg=${envelope.msg ?? ""}`);
|
|
364
|
+
return undefined;
|
|
351
365
|
}
|
|
366
|
+
if (envelope?.code !== undefined && Number(envelope.code) !== 0) return undefined;
|
|
352
367
|
const accessToken = String(envelope?.data?.accessToken ?? "");
|
|
353
368
|
if (accessToken.length === 0) return undefined;
|
|
354
369
|
return {
|
|
@@ -1446,9 +1461,9 @@ function registerRoutes(ctx, runtime) {
|
|
|
1446
1461
|
try {
|
|
1447
1462
|
const started = await workbuddyLoginStart();
|
|
1448
1463
|
runtime.pendingLogins.set(started.state, Date.now());
|
|
1449
|
-
//
|
|
1464
|
+
// 清理超过时限还没完成的登录。
|
|
1450
1465
|
for (const [state, at] of runtime.pendingLogins) {
|
|
1451
|
-
if (Date.now() - at >
|
|
1466
|
+
if (Date.now() - at > LOGIN_TTL_MS) runtime.pendingLogins.delete(state);
|
|
1452
1467
|
}
|
|
1453
1468
|
json(res, 200, { ok: true, authUrl: started.authUrl, state: started.state });
|
|
1454
1469
|
} catch (error) {
|
|
@@ -1456,8 +1471,10 @@ function registerRoutes(ctx, runtime) {
|
|
|
1456
1471
|
}
|
|
1457
1472
|
});
|
|
1458
1473
|
|
|
1459
|
-
// POST /login/
|
|
1460
|
-
|
|
1474
|
+
// POST /login/confirm body: {state}
|
|
1475
|
+
// 由用户主动触发:他在浏览器里登录完成后,回到面板点「我已登录完成」。
|
|
1476
|
+
// 服务端只做一轮**有界**重试,不做无限自动轮询 —— 后台瞎猜不如让用户说一句。
|
|
1477
|
+
add("/login/confirm", async (req, res) => {
|
|
1461
1478
|
if (req.method !== "POST") {
|
|
1462
1479
|
json(res, 405, { error: "method not allowed" });
|
|
1463
1480
|
return;
|
|
@@ -1470,9 +1487,37 @@ function registerRoutes(ctx, runtime) {
|
|
|
1470
1487
|
json(res, 400, { ok: false, error: 'expected {"state": string}' });
|
|
1471
1488
|
return;
|
|
1472
1489
|
}
|
|
1473
|
-
const
|
|
1490
|
+
const startedAt = runtime.pendingLogins.get(state);
|
|
1491
|
+
if (startedAt === undefined) {
|
|
1492
|
+
// 会话里没有这个 state:多半是插件被重载过,或 state 已被回收。
|
|
1493
|
+
json(res, 200, {
|
|
1494
|
+
ok: false,
|
|
1495
|
+
done: true,
|
|
1496
|
+
error: "登录会话已失效,请重新点击「添加账号」",
|
|
1497
|
+
});
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
if (Date.now() - startedAt > LOGIN_TTL_MS) {
|
|
1501
|
+
runtime.pendingLogins.delete(state);
|
|
1502
|
+
json(res, 200, {
|
|
1503
|
+
ok: false,
|
|
1504
|
+
done: true,
|
|
1505
|
+
error: "登录超时(超过 10 分钟),请重新点击「添加账号」",
|
|
1506
|
+
});
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
// 有界重试:最多试 LOGIN_CONFIRM_ATTEMPTS 次,每次间隔 WB.pollIntervalMs。
|
|
1510
|
+
let token;
|
|
1511
|
+
for (let attempt = 1; attempt <= LOGIN_CONFIRM_ATTEMPTS; attempt += 1) {
|
|
1512
|
+
token = await workbuddyLoginPoll(state);
|
|
1513
|
+
if (token !== undefined) break;
|
|
1514
|
+
if (attempt < LOGIN_CONFIRM_ATTEMPTS) {
|
|
1515
|
+
await new Promise((resolve) => setTimeout(resolve, WB.pollIntervalMs));
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1474
1518
|
if (token === undefined) {
|
|
1475
|
-
|
|
1519
|
+
// 还没好:保留登录会话原样返回,让用户稍等几秒再点一次。
|
|
1520
|
+
json(res, 200, { ok: false, done: false, retry: true });
|
|
1476
1521
|
return;
|
|
1477
1522
|
}
|
|
1478
1523
|
let account = { uid: "", enterpriseId: "", nickname: "" };
|