@zhin.js/adapter-icqq 1.1.27 → 1.1.29
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 +45 -0
- package/README.md +59 -9
- package/adapters/icqq/index.js +3 -0
- package/adapters/icqq/index.ts +3 -0
- package/commands/icqq/login/code/index.js +41 -16
- package/commands/icqq/login/code/index.ts +37 -13
- package/commands/icqq/login/confirm/index.js +15 -9
- package/commands/icqq/login/confirm/index.ts +15 -6
- package/commands/icqq/login/definition.js +57 -3
- package/commands/icqq/login/definition.ts +62 -3
- package/commands/icqq/login/qr/index.js +14 -16
- package/commands/icqq/login/qr/index.ts +17 -15
- package/commands/icqq/login/status/index.js +32 -13
- package/commands/icqq/login/status/index.ts +30 -12
- package/lib/endpoint.d.ts +8 -1
- package/lib/endpoint.js +170 -41
- package/lib/i18n.d.ts +1 -1
- package/lib/i18n.js +283 -15
- package/lib/icqq-login-assist.d.ts +113 -20
- package/lib/icqq-login-assist.js +468 -97
- package/lib/icqq-login-control.d.ts +4 -1
- package/lib/icqq-login-descriptors.d.ts +48 -0
- package/lib/icqq-login-descriptors.js +187 -0
- package/lib/icqq-login-errors.d.ts +42 -0
- package/lib/icqq-login-errors.js +77 -0
- package/lib/sdk-logger.d.ts +14 -0
- package/lib/sdk-logger.js +44 -0
- package/package.json +19 -19
- package/src/endpoint.ts +167 -48
- package/src/i18n.ts +283 -15
- package/src/icqq-login-assist.ts +533 -114
- package/src/icqq-login-control.ts +4 -1
- package/src/icqq-login-descriptors.ts +200 -0
- package/src/icqq-login-errors.ts +112 -0
- package/src/sdk-logger.ts +57 -0
package/lib/icqq-login-assist.js
CHANGED
|
@@ -1,111 +1,482 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { LoginChallengeReplacedError, } from '@zhin.js/core';
|
|
2
2
|
import { formatCompact } from '@zhin.js/logger';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
3
|
+
import { messages as t } from './i18n.js';
|
|
4
|
+
import { authDescriptor, deviceDescriptor, ICQQ_SMS_COOLDOWN_MS, qrcodeDescriptor, sliderDescriptor, smsDescriptor, } from './icqq-login-descriptors.js';
|
|
5
|
+
import { classifyIcqqSmsFailure, mapIcqqLoginError, sanitizeIcqqLoginMessage, } from './icqq-login-errors.js';
|
|
6
|
+
/** `QrcodeResult` of the SDK. */
|
|
7
|
+
export const ICQQ_QR_RESULT = Object.freeze({ ok: 0, timeout: 17, waitingForScan: 48, waitingForConfirm: 53, canceled: 54 });
|
|
8
|
+
export const ICQQ_QR_POLL_INTERVAL_MS = 2_000;
|
|
9
|
+
/** Expired QR codes replaced without being asked, per flow; then the flow waits for the person. */
|
|
10
|
+
export const ICQQ_QR_AUTO_REFRESH_LIMIT = 2;
|
|
11
|
+
export class IcqqLoginDriver {
|
|
12
|
+
#o;
|
|
13
|
+
#flow;
|
|
14
|
+
/** Invalidates the running challenge step when a newer event takes over. */
|
|
15
|
+
#token = 0;
|
|
16
|
+
#running = false;
|
|
17
|
+
/**
|
|
18
|
+
* Last challenge of this attempt. `nt` is fixed when a slider arrives: the SDK clears
|
|
19
|
+
* `sig.nt_captcha` once a ticket is submitted, so it is never recomputed.
|
|
20
|
+
*/
|
|
21
|
+
#last;
|
|
22
|
+
#qrAutoRefreshes = 0;
|
|
23
|
+
#pollTimer;
|
|
24
|
+
#smsSentAt = 0;
|
|
25
|
+
constructor(options) {
|
|
26
|
+
this.#o = options;
|
|
27
|
+
}
|
|
28
|
+
/** The live flow of this attempt, if any. */
|
|
29
|
+
get flow() {
|
|
30
|
+
return this.#flow && isLive(this.#flow) ? this.#flow : undefined;
|
|
31
|
+
}
|
|
32
|
+
/** Kind of the last challenge this attempt raised. */
|
|
33
|
+
get lastChallenge() {
|
|
34
|
+
return this.#last?.kind;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A person is (or is about to be) answering a challenge of this account: automatic relogin
|
|
38
|
+
* must wait. Covers the step being driven and any active challenge of this Endpoint key.
|
|
39
|
+
*/
|
|
40
|
+
get busy() {
|
|
41
|
+
return this.#running || this.#o.assist.hasActiveChallenge(this.#o.endpointKey, 'icqq');
|
|
42
|
+
}
|
|
43
|
+
/** The attempt ended in a state that waits for the person: failed, or an expired challenge. */
|
|
44
|
+
get awaitingPerson() {
|
|
45
|
+
const state = this.#flow?.snapshot().state;
|
|
46
|
+
return state === 'failed' || state === 'expired';
|
|
47
|
+
}
|
|
48
|
+
/** Start a new attempt (supersedes the previous flow of this Endpoint instance). */
|
|
49
|
+
begin(title = t(this.#o.locale, 'loginConnecting')) {
|
|
50
|
+
this.invalidate();
|
|
51
|
+
if (this.#o.signal?.aborted)
|
|
52
|
+
return undefined;
|
|
53
|
+
try {
|
|
54
|
+
this.#flow = this.#o.assist.beginFlow({
|
|
55
|
+
adapter: 'icqq', endpointKey: this.#o.endpointKey, owner: this.#o.owner,
|
|
56
|
+
...(this.#o.signal ? { signal: this.#o.signal } : {}),
|
|
57
|
+
...(this.#o.endpointIdentity ? { endpointIdentity: this.#o.endpointIdentity } : {}),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
this.#last = undefined;
|
|
64
|
+
this.#qrAutoRefreshes = 0;
|
|
65
|
+
// #smsSentAt survives a new attempt: QQ's SMS limit is per account, not per login flow.
|
|
66
|
+
if (title)
|
|
67
|
+
this.#flow.step('connect', title);
|
|
68
|
+
return this.#flow;
|
|
69
|
+
}
|
|
70
|
+
/** Another login call inside the current attempt (automatic retry); begins one when none is live. */
|
|
71
|
+
connect(title) {
|
|
72
|
+
const flow = this.flow;
|
|
73
|
+
if (flow)
|
|
74
|
+
flow.step('connect', title);
|
|
75
|
+
else
|
|
76
|
+
this.begin(title);
|
|
77
|
+
}
|
|
78
|
+
/** Timeline mark (e.g. a scheduled retry); begins an attempt when none is live. */
|
|
79
|
+
info(text) {
|
|
80
|
+
(this.flow ?? this.begin(null))?.step('info', text);
|
|
81
|
+
}
|
|
82
|
+
outcome(outcome) {
|
|
83
|
+
(this.flow ?? this.begin(null))?.outcome(outcome);
|
|
84
|
+
}
|
|
85
|
+
fail(code, message, next) {
|
|
86
|
+
this.invalidate();
|
|
87
|
+
(this.flow ?? this.begin(null))?.fail(code, message, next);
|
|
88
|
+
}
|
|
89
|
+
online() {
|
|
90
|
+
this.invalidate();
|
|
91
|
+
this.#flow?.online();
|
|
92
|
+
this.#last = undefined;
|
|
93
|
+
}
|
|
94
|
+
/** Stop driving the current step (a newer event, online, error or stop took over). */
|
|
95
|
+
invalidate() {
|
|
96
|
+
this.#token += 1;
|
|
97
|
+
this.#running = false;
|
|
98
|
+
this.#stopPoll();
|
|
99
|
+
}
|
|
100
|
+
dispose() {
|
|
101
|
+
this.invalidate();
|
|
102
|
+
this.#flow = undefined;
|
|
103
|
+
}
|
|
104
|
+
/** Open a challenge for an SDK event and drive it. */
|
|
105
|
+
challenge(kind, event) {
|
|
106
|
+
this.#open(kind, event, kind === 'slider' ? isNtCaptcha(this.#o.client) : undefined);
|
|
107
|
+
}
|
|
108
|
+
#open(kind, event, nt) {
|
|
109
|
+
if (this.#o.signal?.aborted)
|
|
110
|
+
return;
|
|
111
|
+
const flow = this.flow ?? this.begin(null);
|
|
112
|
+
if (!flow)
|
|
113
|
+
return;
|
|
114
|
+
this.invalidate();
|
|
115
|
+
const token = this.#token;
|
|
116
|
+
this.#running = true;
|
|
117
|
+
this.#last = { kind, event, ...(nt !== undefined ? { nt } : {}) };
|
|
118
|
+
this.#o.logger.info(formatCompact({
|
|
119
|
+
op: 'login_assist_challenge',
|
|
120
|
+
endpoint: this.#o.endpointKey,
|
|
121
|
+
type: kind,
|
|
122
|
+
// The link carries a session id (sid / sig): only its host is logged.
|
|
123
|
+
...(icqqLinkHost(event.url) ? { host: icqqLinkHost(event.url) } : {}),
|
|
124
|
+
}));
|
|
125
|
+
void this.#drive(kind, event, token, nt === true).catch((error) => {
|
|
126
|
+
if (token !== this.#token)
|
|
127
|
+
return;
|
|
128
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
129
|
+
this.#o.logger.warn(formatCompact({ op: 'login_assist_failed', endpoint: this.#o.endpointKey, type: kind, error: message }));
|
|
130
|
+
const mapped = mapIcqqLoginError({ message }, this.#o.locale);
|
|
131
|
+
this.#flow?.fail(mapped.code, t(this.#o.locale, 'loginSdkCallFailed', { error: sanitizeIcqqLoginMessage(message) }), mapped.next);
|
|
132
|
+
}).finally(() => {
|
|
133
|
+
if (token === this.#token)
|
|
134
|
+
this.#running = false;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* `system.login.error`: a wrong code / ticket re-opens the same challenge (`retry_input`);
|
|
139
|
+
* otherwise the caller decides between failing the flow and an automatic retry.
|
|
140
|
+
*/
|
|
141
|
+
loginError(event) {
|
|
142
|
+
const mapping = mapIcqqLoginError(event, this.#o.locale, this.#o.endpointKey);
|
|
143
|
+
const last = this.#last;
|
|
144
|
+
this.invalidate();
|
|
145
|
+
if (mapping.code === 'code_invalid' && last?.kind === 'slider' && last.nt) {
|
|
146
|
+
// The NT captcha is consumed by the submit (the SDK cleared `sig.nt_captcha`): the same link
|
|
147
|
+
// cannot take another ticket. Log in again for a fresh slider.
|
|
148
|
+
this.outcome({ result: 'rejected', code: 'code_invalid', message: mapping.message, next: 'wait' });
|
|
149
|
+
this.#running = true;
|
|
150
|
+
const token = this.#token;
|
|
151
|
+
void Promise.resolve().then(() => this.#o.client.login()).catch((error) => {
|
|
152
|
+
if (token !== this.#token)
|
|
153
|
+
return;
|
|
154
|
+
this.#o.logger.warn(formatCompact({ op: 'login_assist_slider_retry_failed', endpoint: this.#o.endpointKey,
|
|
155
|
+
error: error instanceof Error ? error.message : String(error) }));
|
|
156
|
+
this.fail('code_invalid', mapping.message, 'relogin');
|
|
157
|
+
}).finally(() => { if (token === this.#token)
|
|
158
|
+
this.#running = false; });
|
|
159
|
+
return { mapping, hadChallenge: true, retrying: true };
|
|
160
|
+
}
|
|
161
|
+
if (mapping.code === 'code_invalid' && last && (last.kind === 'sms' || last.kind === 'slider')) {
|
|
162
|
+
this.outcome({ result: 'rejected', code: 'code_invalid', message: mapping.message, next: 'retry_input' });
|
|
163
|
+
this.#open(last.kind, last.event, last.nt);
|
|
164
|
+
return { mapping, hadChallenge: true, retrying: true };
|
|
165
|
+
}
|
|
166
|
+
return { mapping, hadChallenge: !!last, retrying: false };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* `internal.error.qrcode` after a QR login call (the SDK's second scan query failed). An expired
|
|
170
|
+
* or cancelled code is replaced by the SDK itself; anything else shows the same QR code again.
|
|
171
|
+
*/
|
|
172
|
+
qrcodeError(code, message) {
|
|
173
|
+
const last = this.#last;
|
|
174
|
+
const flow = this.flow;
|
|
175
|
+
if (!flow || last?.kind !== 'qrcode' || flow.snapshot().activeTaskId)
|
|
176
|
+
return;
|
|
177
|
+
const text = sanitizeIcqqLoginMessage(message) || t(this.#o.locale, 'qrExpired');
|
|
178
|
+
if (code === ICQQ_QR_RESULT.timeout || code === ICQQ_QR_RESULT.canceled) {
|
|
179
|
+
this.invalidate();
|
|
180
|
+
flow.outcome({ result: 'expired', code: 'challenge_expired', message: text, next: 'refresh_challenge' });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const known = code === ICQQ_QR_RESULT.waitingForScan || code === ICQQ_QR_RESULT.waitingForConfirm;
|
|
184
|
+
flow.outcome({ result: 'rejected', ...(known ? {} : { code: 'unknown' }), message: text, next: 'retry_input' });
|
|
185
|
+
this.#open('qrcode', last.event, undefined);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Refresh the current challenge. Only a QR code can be refreshed in place (a new QR code from the
|
|
189
|
+
* SDK); returns false when the caller should log in again instead.
|
|
190
|
+
*/
|
|
191
|
+
async refresh() {
|
|
192
|
+
const client = this.#o.client;
|
|
193
|
+
if (this.#last?.kind !== 'qrcode' || !this.flow || typeof client.fetchQrcode !== 'function')
|
|
194
|
+
return false;
|
|
195
|
+
this.#flow.step('info', t(this.#o.locale, 'qrRefreshing'));
|
|
196
|
+
await client.fetchQrcode();
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
// --------------------------------------------------------------------------
|
|
200
|
+
async #drive(kind, event, token, nt) {
|
|
201
|
+
switch (kind) {
|
|
202
|
+
case 'qrcode': return this.#driveQr(event, token);
|
|
203
|
+
case 'slider': return this.#driveSlider(event, token, nt);
|
|
204
|
+
case 'device': return this.#driveDevice(event, token);
|
|
205
|
+
case 'sms': return this.#driveSms(event, token);
|
|
206
|
+
case 'auth': return this.#driveAuth(event, token);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** Open a challenge; undefined when it was replaced, cancelled, expired or the Endpoint retired. */
|
|
210
|
+
async #ask(token, descriptor, onOpen) {
|
|
211
|
+
const flow = this.#flow;
|
|
212
|
+
if (!flow || token !== this.#token || this.#o.signal?.aborted)
|
|
213
|
+
return undefined;
|
|
214
|
+
const pending = flow.challenge(descriptor);
|
|
215
|
+
const taskId = flow.snapshot().activeTaskId;
|
|
216
|
+
if (taskId)
|
|
217
|
+
onOpen?.(taskId);
|
|
218
|
+
try {
|
|
219
|
+
const answer = await pending;
|
|
220
|
+
if (token !== this.#token || this.#o.signal?.aborted)
|
|
221
|
+
return undefined;
|
|
222
|
+
return answer;
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
if (!(error instanceof LoginChallengeReplacedError) && token === this.#token) {
|
|
226
|
+
this.#o.logger.info(formatCompact({
|
|
227
|
+
op: 'login_assist_challenge_ended',
|
|
228
|
+
endpoint: this.#o.endpointKey,
|
|
229
|
+
type: descriptor.kind,
|
|
230
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
// A newer challenge owns the poll timer once the token moved on.
|
|
237
|
+
if (token === this.#token)
|
|
238
|
+
this.#stopPoll();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
#current(token) {
|
|
242
|
+
return token === this.#token && !this.#o.signal?.aborted ? this.#flow : undefined;
|
|
243
|
+
}
|
|
244
|
+
// --- qrcode ---------------------------------------------------------------
|
|
245
|
+
async #driveQr(event, token) {
|
|
246
|
+
let scanned = false;
|
|
247
|
+
const answer = await this.#ask(token, qrcodeDescriptor({ image: bufferToDataUrl(event.image) }, this.#o.locale), (taskId) => this.#startQrPoll(token, taskId, () => { scanned = true; }));
|
|
248
|
+
if (!answer)
|
|
249
|
+
return;
|
|
250
|
+
const client = this.#o.client;
|
|
251
|
+
const canQuery = typeof client.queryQrcodeResult === 'function' && typeof client.qrcodeLogin === 'function';
|
|
252
|
+
if (canQuery && !scanned) {
|
|
253
|
+
let retcode;
|
|
22
254
|
try {
|
|
23
|
-
await client.
|
|
255
|
+
retcode = (await client.queryQrcodeResult()).retcode;
|
|
24
256
|
}
|
|
25
|
-
catch
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
257
|
+
catch {
|
|
258
|
+
retcode = -1;
|
|
259
|
+
}
|
|
260
|
+
const flow = this.#current(token);
|
|
261
|
+
if (!flow)
|
|
262
|
+
return;
|
|
263
|
+
if (retcode === ICQQ_QR_RESULT.waitingForScan || retcode === ICQQ_QR_RESULT.waitingForConfirm || retcode < 0) {
|
|
264
|
+
const message = retcode === ICQQ_QR_RESULT.waitingForScan ? t(this.#o.locale, 'qrNotScanned')
|
|
265
|
+
: retcode === ICQQ_QR_RESULT.waitingForConfirm ? t(this.#o.locale, 'qrScanned')
|
|
266
|
+
: t(this.#o.locale, 'loginErrorNetwork');
|
|
267
|
+
flow.outcome({ result: 'rejected', ...(retcode < 0 ? { code: 'network' } : {}), message, next: 'retry_input' });
|
|
268
|
+
return this.#driveQr(event, token);
|
|
269
|
+
}
|
|
270
|
+
if (retcode !== ICQQ_QR_RESULT.ok) {
|
|
271
|
+
flow.outcome({ result: 'expired', code: 'challenge_expired', message: t(this.#o.locale, 'qrExpired'), next: 'refresh_challenge' });
|
|
272
|
+
await this.#newQr();
|
|
273
|
+
return;
|
|
31
274
|
}
|
|
32
275
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
break;
|
|
41
|
-
case 'slider':
|
|
42
|
-
await client.submitSlider(text);
|
|
43
|
-
break;
|
|
44
|
-
case 'device':
|
|
45
|
-
case 'sms':
|
|
46
|
-
await client.submitSmsCode(text);
|
|
47
|
-
break;
|
|
48
|
-
default:
|
|
49
|
-
await client.login();
|
|
50
|
-
break;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
catch (error) {
|
|
54
|
-
logger.warn(formatCompact({
|
|
55
|
-
op: 'login_assist_failed',
|
|
56
|
-
endpoint: endpointKey,
|
|
57
|
-
type: step,
|
|
58
|
-
error: error instanceof Error ? error.message : String(error),
|
|
59
|
-
}));
|
|
276
|
+
if (!this.#current(token))
|
|
277
|
+
return;
|
|
278
|
+
this.#flow.verifying(t(this.#o.locale, 'loginVerifying'));
|
|
279
|
+
if (canQuery)
|
|
280
|
+
await client.qrcodeLogin();
|
|
281
|
+
else
|
|
282
|
+
await client.login();
|
|
60
283
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
284
|
+
async #newQr() {
|
|
285
|
+
const client = this.#o.client;
|
|
286
|
+
if (typeof client.fetchQrcode === 'function')
|
|
287
|
+
await client.fetchQrcode();
|
|
288
|
+
else
|
|
289
|
+
await client.login();
|
|
290
|
+
}
|
|
291
|
+
/** Follow the scan while the QR code is shown: scanned → info step; confirmed → continue. */
|
|
292
|
+
#startQrPoll(token, taskId, confirmed) {
|
|
293
|
+
const client = this.#o.client;
|
|
294
|
+
if (typeof client.queryQrcodeResult !== 'function' || typeof client.qrcodeLogin !== 'function')
|
|
295
|
+
return;
|
|
296
|
+
this.#stopPoll();
|
|
297
|
+
let announced = false;
|
|
298
|
+
const active = () => token === this.#token && !this.#o.signal?.aborted && this.#flow?.snapshot().activeTaskId === taskId;
|
|
299
|
+
const schedule = () => {
|
|
300
|
+
this.#pollTimer = setTimeout(() => { void tick(); }, this.#o.qrPollIntervalMs ?? ICQQ_QR_POLL_INTERVAL_MS);
|
|
301
|
+
this.#pollTimer.unref?.();
|
|
302
|
+
};
|
|
303
|
+
const tick = async () => {
|
|
304
|
+
this.#pollTimer = undefined;
|
|
305
|
+
if (!active())
|
|
306
|
+
return;
|
|
307
|
+
let retcode;
|
|
308
|
+
try {
|
|
309
|
+
retcode = (await client.queryQrcodeResult()).retcode;
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
retcode = undefined;
|
|
313
|
+
}
|
|
314
|
+
if (!active())
|
|
315
|
+
return;
|
|
316
|
+
const flow = this.#flow;
|
|
317
|
+
if (retcode === ICQQ_QR_RESULT.ok) {
|
|
318
|
+
confirmed();
|
|
319
|
+
try {
|
|
320
|
+
this.#o.assist.submit(taskId, { action: 'confirm', values: {} }, this.#o.endpointIdentity);
|
|
321
|
+
}
|
|
322
|
+
catch { /* the challenge ended meanwhile */ }
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (retcode === ICQQ_QR_RESULT.waitingForConfirm && !announced) {
|
|
326
|
+
announced = true;
|
|
327
|
+
flow.step('info', t(this.#o.locale, 'qrScanned'));
|
|
328
|
+
}
|
|
329
|
+
if (retcode === ICQQ_QR_RESULT.timeout || retcode === ICQQ_QR_RESULT.canceled) {
|
|
330
|
+
if (this.#qrAutoRefreshes < ICQQ_QR_AUTO_REFRESH_LIMIT) {
|
|
331
|
+
this.#qrAutoRefreshes += 1;
|
|
332
|
+
flow.step('info', t(this.#o.locale, 'qrExpiredRefreshing'));
|
|
333
|
+
void this.#newQr().catch((error) => {
|
|
334
|
+
this.#o.logger.warn(formatCompact({ op: 'login_assist_qr_refresh_failed', endpoint: this.#o.endpointKey, error: error instanceof Error ? error.message : String(error) }));
|
|
335
|
+
});
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
flow.outcome({ result: 'expired', code: 'challenge_expired', message: t(this.#o.locale, 'qrExpired'), next: 'refresh_challenge' });
|
|
339
|
+
flow.end('expired');
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
schedule();
|
|
343
|
+
};
|
|
344
|
+
schedule();
|
|
345
|
+
}
|
|
346
|
+
#stopPoll() {
|
|
347
|
+
if (this.#pollTimer)
|
|
348
|
+
clearTimeout(this.#pollTimer);
|
|
349
|
+
this.#pollTimer = undefined;
|
|
350
|
+
}
|
|
351
|
+
// --- slider ---------------------------------------------------------------
|
|
352
|
+
async #driveSlider(event, token, nt) {
|
|
353
|
+
const url = typeof event.url === 'string' && event.url ? event.url : undefined;
|
|
354
|
+
for (;;) {
|
|
355
|
+
const answer = await this.#ask(token, sliderDescriptor(url, this.#o.locale));
|
|
356
|
+
if (!answer)
|
|
357
|
+
return;
|
|
358
|
+
const flow = this.#flow;
|
|
359
|
+
const ticket = answer.values.input?.trim();
|
|
360
|
+
const randstr = answer.values.randstr?.trim();
|
|
361
|
+
if (!ticket || (nt && !randstr)) {
|
|
362
|
+
flow.outcome({
|
|
363
|
+
result: 'rejected', code: 'code_invalid', next: 'retry_input',
|
|
364
|
+
message: t(this.#o.locale, ticket ? 'sliderRandstrMissing' : 'loginErrorCodeInvalid'),
|
|
365
|
+
});
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
flow.verifying(t(this.#o.locale, 'loginVerifyingSlider'));
|
|
369
|
+
await this.#o.client.submitSlider(icqqSliderSubmission({ ticket, randstr }, nt));
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
// --- device / sms ---------------------------------------------------------
|
|
374
|
+
async #driveDevice(event, token) {
|
|
375
|
+
const url = typeof event.url === 'string' && event.url ? event.url : undefined;
|
|
376
|
+
const phone = typeof event.phone === 'string' && event.phone ? event.phone : undefined;
|
|
377
|
+
for (;;) {
|
|
378
|
+
const answer = await this.#ask(token, deviceDescriptor({ url, phone }, this.#o.locale));
|
|
379
|
+
if (!answer)
|
|
380
|
+
return;
|
|
381
|
+
if (answer.actionId === 'sms') {
|
|
382
|
+
if (!await this.#sendSms(token))
|
|
383
|
+
continue;
|
|
384
|
+
if (!this.#current(token))
|
|
385
|
+
return;
|
|
386
|
+
this.#last = { kind: 'sms', event };
|
|
387
|
+
return this.#driveSms(event, token);
|
|
388
|
+
}
|
|
389
|
+
this.#flow.verifying(t(this.#o.locale, 'loginVerifyingDevice'));
|
|
390
|
+
await this.#o.client.login();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
async #driveSms(event, token) {
|
|
395
|
+
const phone = typeof event.phone === 'string' && event.phone ? event.phone : undefined;
|
|
396
|
+
for (;;) {
|
|
397
|
+
const answer = await this.#ask(token, smsDescriptor({ phone }, this.#o.locale));
|
|
398
|
+
if (!answer)
|
|
399
|
+
return;
|
|
400
|
+
if (answer.actionId === 'resend') {
|
|
401
|
+
if (await this.#sendSms(token))
|
|
402
|
+
this.#current(token)?.step('info', t(this.#o.locale, 'smsResent'));
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
this.#flow.verifying(t(this.#o.locale, 'loginVerifyingSms'));
|
|
406
|
+
await this.#o.client.submitSmsCode((answer.values.input ?? '').trim());
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/** Request an SMS code; a failure (or the cooldown) is reported on the step and returns false. */
|
|
411
|
+
async #sendSms(token) {
|
|
412
|
+
const waitMs = this.#smsSentAt + ICQQ_SMS_COOLDOWN_MS - Date.now();
|
|
413
|
+
if (this.#smsSentAt > 0 && waitMs > 0) {
|
|
414
|
+
this.#flow?.outcome({
|
|
415
|
+
result: 'rejected', code: 'rate_limited', next: 'wait', retryAfterMs: waitMs,
|
|
416
|
+
message: t(this.#o.locale, 'loginErrorRateLimited'),
|
|
417
|
+
});
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
await this.#o.client.sendSmsCode();
|
|
422
|
+
this.#smsSentAt = Date.now();
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
catch (error) {
|
|
426
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
427
|
+
this.#o.logger.warn(formatCompact({ op: 'login_assist_send_sms_failed', endpoint: this.#o.endpointKey, error: message }));
|
|
428
|
+
const code = classifyIcqqSmsFailure(error);
|
|
429
|
+
this.#current(token)?.outcome({
|
|
430
|
+
result: 'rejected', code, next: code === 'rate_limited' ? 'wait' : 'retry_input',
|
|
431
|
+
message: t(this.#o.locale, 'smsSendFailed', { error: sanitizeIcqqLoginMessage(message) || t(this.#o.locale, 'loginErrorUnknown') }),
|
|
432
|
+
});
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// --- auth -----------------------------------------------------------------
|
|
437
|
+
async #driveAuth(event, token) {
|
|
438
|
+
const url = typeof event.url === 'string' && event.url ? event.url : undefined;
|
|
439
|
+
const answer = await this.#ask(token, authDescriptor({ url, device: event.device }, this.#o.locale));
|
|
440
|
+
if (!answer)
|
|
441
|
+
return;
|
|
442
|
+
this.#flow.verifying(t(this.#o.locale, 'loginVerifyingAuth'));
|
|
443
|
+
await this.#o.client.login();
|
|
89
444
|
}
|
|
90
445
|
}
|
|
91
|
-
function
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (typeof value.ticket === 'string')
|
|
95
|
-
return value.ticket.trim();
|
|
96
|
-
if (typeof value.code === 'string')
|
|
97
|
-
return value.code.trim();
|
|
98
|
-
if (typeof value.value === 'string')
|
|
99
|
-
return value.value.trim();
|
|
100
|
-
return JSON.stringify(value);
|
|
446
|
+
function isLive(flow) {
|
|
447
|
+
const state = flow.snapshot().state;
|
|
448
|
+
return state === 'active' || state === 'expired';
|
|
101
449
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
450
|
+
/**
|
|
451
|
+
* The SDK's own rule (`submitSlider`): a truthy `sig.nt_captcha` means the NT path, which splits
|
|
452
|
+
* `ticket,randstr`; otherwise the legacy wtlogin path puts the raw text into TLV 0x193, so it must
|
|
453
|
+
* get the ticket alone. Read once, when the slider arrives (submitting clears it).
|
|
454
|
+
*/
|
|
455
|
+
export function isNtCaptcha(client) {
|
|
456
|
+
const sig = client.sig;
|
|
457
|
+
return !!sig && typeof sig === 'object' && !!sig.nt_captcha;
|
|
458
|
+
}
|
|
459
|
+
/** What `submitSlider` receives: `ticket,randstr` on the NT path, the ticket alone otherwise. */
|
|
460
|
+
export function icqqSliderSubmission(value, nt) {
|
|
461
|
+
return nt && value.randstr ? `${value.ticket},${value.randstr}` : value.ticket;
|
|
462
|
+
}
|
|
463
|
+
/** Host name of an http(s) link (what logs may show of a verification link), or undefined. */
|
|
464
|
+
export function icqqLinkHost(url) {
|
|
465
|
+
if (typeof url !== 'string' || !url)
|
|
466
|
+
return undefined;
|
|
467
|
+
try {
|
|
468
|
+
const parsed = new URL(url);
|
|
469
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.hostname : undefined;
|
|
105
470
|
}
|
|
106
|
-
|
|
107
|
-
return
|
|
471
|
+
catch {
|
|
472
|
+
return undefined;
|
|
108
473
|
}
|
|
474
|
+
}
|
|
475
|
+
export function bufferToDataUrl(image) {
|
|
476
|
+
if (Buffer.isBuffer(image))
|
|
477
|
+
return `data:image/png;base64,${image.toString('base64')}`;
|
|
478
|
+
if (image instanceof Uint8Array)
|
|
479
|
+
return `data:image/png;base64,${Buffer.from(image).toString('base64')}`;
|
|
109
480
|
if (typeof image === 'string' && image.trim())
|
|
110
481
|
return image.trim();
|
|
111
482
|
return undefined;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { FeatureId, PluginId } from 'zhin.js';
|
|
2
|
-
import type { PendingLoginTask } from '@zhin.js/core';
|
|
2
|
+
import type { LoginFlowSnapshot, PendingLoginTask } from '@zhin.js/core';
|
|
3
3
|
export interface IcqqLoginControlState {
|
|
4
4
|
readonly endpointId: string;
|
|
5
5
|
readonly transportState: string;
|
|
@@ -10,8 +10,11 @@ export interface IcqqLoginControl {
|
|
|
10
10
|
readonly endpointId: string;
|
|
11
11
|
state(): IcqqLoginControlState;
|
|
12
12
|
pending(): readonly PendingLoginTask[];
|
|
13
|
+
/** Latest login flow of this incarnation (ADR 0058), including the active challenge. */
|
|
14
|
+
flow(): LoginFlowSnapshot | undefined;
|
|
13
15
|
/** Force a login attempt; rejects when the endpoint is stopped or the SDK login fails. */
|
|
14
16
|
relogin(): Promise<void>;
|
|
17
|
+
/** Answer a challenge; throws `LoginSubmissionError` when the answer does not fit it. */
|
|
15
18
|
submit(taskId: string, value: string | Record<string, unknown>): boolean;
|
|
16
19
|
cancel(taskId: string, reason?: string): boolean;
|
|
17
20
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Challenge descriptors of the ICQQ login flow (ADR 0058 §2–3).
|
|
3
|
+
*
|
|
4
|
+
* The guidance text lives here (not in the SDK): the Console, the terminal and the QQ-chat
|
|
5
|
+
* commands all render these descriptors. Copy blocks with device data are `sensitive`; the helper
|
|
6
|
+
* script is static text and never contains device or user data.
|
|
7
|
+
*/
|
|
8
|
+
import type { Locale } from 'zhin.js/i18n';
|
|
9
|
+
import type { LoginChallengeDescriptor } from '@zhin.js/core';
|
|
10
|
+
/** Challenge ids are stable per kind so the Console can keep UI state across re-opened challenges. */
|
|
11
|
+
export declare const ICQQ_CHALLENGE_IDS: Readonly<{
|
|
12
|
+
qrcode: "icqq-qrcode";
|
|
13
|
+
slider: "icqq-slider";
|
|
14
|
+
device: "icqq-device";
|
|
15
|
+
sms: "icqq-sms";
|
|
16
|
+
auth: "icqq-auth";
|
|
17
|
+
}>;
|
|
18
|
+
/** SMS resend cooldown (QQ refuses a second code within a minute). */
|
|
19
|
+
export declare const ICQQ_SMS_COOLDOWN_MS = 60000;
|
|
20
|
+
/** Page the `auth` helper script runs on (QQ's "send verification code" page). */
|
|
21
|
+
export declare const ICQQ_AUTH_SCRIPT_HOST = "accounts.qq.com";
|
|
22
|
+
export declare const ICQQ_AUTH_SCRIPT_PATH = "/login/attack";
|
|
23
|
+
export declare function qrcodeDescriptor(input: {
|
|
24
|
+
image?: string;
|
|
25
|
+
}, locale?: Locale): LoginChallengeDescriptor;
|
|
26
|
+
export declare function sliderDescriptor(url: string | undefined, locale?: Locale): LoginChallengeDescriptor;
|
|
27
|
+
export declare function deviceDescriptor(input: {
|
|
28
|
+
url?: string;
|
|
29
|
+
phone?: string;
|
|
30
|
+
}, locale?: Locale): LoginChallengeDescriptor;
|
|
31
|
+
export declare function smsDescriptor(input: {
|
|
32
|
+
phone?: string;
|
|
33
|
+
}, locale?: Locale): LoginChallengeDescriptor;
|
|
34
|
+
export declare function authDescriptor(input: {
|
|
35
|
+
url?: string;
|
|
36
|
+
device?: unknown;
|
|
37
|
+
}, locale?: Locale): LoginChallengeDescriptor;
|
|
38
|
+
/** Query string of the slider URL (for the hosted captcha helper), or undefined for a non-http URL. */
|
|
39
|
+
export declare function sliderCaptchaQuery(url: string): string | undefined;
|
|
40
|
+
/** Device information of `system.login.auth` as pretty JSON (string fields only), or undefined. */
|
|
41
|
+
export declare function formatDeviceJson(device: unknown): string | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* Static helper script for the `auth` challenge. It runs only on the QQ verification page, asks for
|
|
44
|
+
* the device JSON with `prompt()`, checks it, sets `window.__INITIAL_STATE__.deviceInfo` and reports
|
|
45
|
+
* the result. It never submits anything and contains no device or user data. No `//` comments: the
|
|
46
|
+
* same text becomes a one-line bookmarklet.
|
|
47
|
+
*/
|
|
48
|
+
export declare function icqqAuthHelperScript(locale?: Locale): string;
|