@zhin.js/adapter-icqq 1.1.28 → 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.
@@ -1,139 +1,558 @@
1
+ /**
2
+ * ICQQ login flow driver (ADR 0058 Login Assist v2).
3
+ *
4
+ * One LoginFlow per login attempt: connect → challenge(s) → verifying → online / failed. The SDK's
5
+ * `system.login.*` events open challenges; the person's answer drives the SDK call; the SDK's next
6
+ * event (a new challenge, `system.login.error` or `system.online`) is the result.
7
+ *
8
+ * - qrcode: polls the scan state while the QR code is shown (scanned → info step; confirmed →
9
+ * continues by itself); "I confirmed" checks the state before logging in.
10
+ * - slider: hosted captcha helper (`media.captcha`) or a pasted ticket / JSON / URL; the NT login
11
+ * path needs `ticket,randstr`, the legacy wtlogin path the ticket alone.
12
+ * - device: confirm on the phone, or ask for an SMS code (sent only on request).
13
+ * - auth: device JSON + static helper script + verification link, then log in again.
14
+ */
1
15
  import type { Locale } from 'zhin.js/i18n';
2
- import { messages as t } from './i18n.js';
3
- import type { LoginAssist, LoginAssistEndpointIdentity, LoginAssistType, PendingLoginTaskPayload } from '@zhin.js/core';
16
+ import {
17
+ LoginChallengeReplacedError,
18
+ type LoginAssist,
19
+ type LoginAssistEndpointIdentity,
20
+ type LoginChallengeAnswer,
21
+ type LoginChallengeDescriptor,
22
+ type LoginErrorCode,
23
+ type LoginFlow,
24
+ type LoginNextAction,
25
+ type LoginOutcome,
26
+ } from '@zhin.js/core';
4
27
  import { formatCompact } from '@zhin.js/logger';
28
+ import { messages as t } from './i18n.js';
29
+ import {
30
+ authDescriptor,
31
+ deviceDescriptor,
32
+ ICQQ_SMS_COOLDOWN_MS,
33
+ qrcodeDescriptor,
34
+ sliderDescriptor,
35
+ smsDescriptor,
36
+ } from './icqq-login-descriptors.js';
37
+ import {
38
+ classifyIcqqSmsFailure,
39
+ mapIcqqLoginError,
40
+ sanitizeIcqqLoginMessage,
41
+ type IcqqLoginErrorMapping,
42
+ } from './icqq-login-errors.js';
5
43
 
6
- type IcqqLoginClient = {
7
- login(password?: string): Promise<unknown>;
44
+ /** The SDK surface the driver uses (the Endpoint is the SDK Client). */
45
+ export interface IcqqLoginClient {
46
+ login(): Promise<unknown>;
8
47
  submitSlider(ticket: string): Promise<unknown>;
9
48
  sendSmsCode(): Promise<unknown>;
10
49
  submitSmsCode(code: string): Promise<unknown>;
11
- };
50
+ /** QR scan state; absent on SDK builds without QR login. */
51
+ queryQrcodeResult?(): Promise<{ retcode: number }>;
52
+ qrcodeLogin?(): Promise<unknown>;
53
+ fetchQrcode?(): Promise<unknown>;
54
+ readonly sig?: unknown;
55
+ }
12
56
 
13
- type Logger = {
14
- info(message: string): void;
15
- warn(message: string): void;
16
- };
57
+ type Logger = { info(message: string): void; warn(message: string): void };
17
58
 
18
- /**
19
- * Drive ICQQ login challenges through LoginAssist (refresh-safe pending tasks).
20
- * Mirrors the classic stdin flow: qrcode/auth → login(); slider → submitSlider;
21
- * device → sendSmsCode then submitSmsCode.
22
- */
23
- export async function runIcqqLoginAssistStep(input: {
24
- assist: LoginAssist;
25
- client: IcqqLoginClient;
26
- adapter: string;
27
- endpointKey: string;
28
- owner: object;
29
- signal?: AbortSignal;
30
- endpointIdentity?: LoginAssistEndpointIdentity;
31
- logger: Logger;
32
- step: LoginAssistType;
33
- event: unknown;
34
- locale?: Locale;
35
- }): Promise<void> {
36
- const { assist, client, adapter, endpointKey, owner, logger, step, event } = input;
37
- assist.cancelOwned(owner, `replaced_by_${step}`);
38
-
39
- const payload = buildPayload(step, event, input.locale);
40
- logger.info(formatCompact({
41
- op: 'login_assist_pending',
42
- endpoint: endpointKey,
43
- type: step,
44
- message: payload.message,
45
- ...(typeof payload.url === 'string' ? { url: payload.url } : {}),
46
- }));
59
+ export type IcqqChallengeKind = 'qrcode' | 'slider' | 'device' | 'sms' | 'auth';
47
60
 
48
- try {
49
- input.signal?.throwIfAborted();
50
- if (step === 'device') {
51
- try {
52
- await client.sendSmsCode();
53
- } catch (error) {
54
- logger.warn(formatCompact({
55
- op: 'login_assist_send_sms_failed',
56
- endpoint: endpointKey,
57
- error: error instanceof Error ? error.message : String(error),
61
+ /** `QrcodeResult` of the SDK. */
62
+ export const ICQQ_QR_RESULT = Object.freeze({ ok: 0, timeout: 17, waitingForScan: 48, waitingForConfirm: 53, canceled: 54 });
63
+ export const ICQQ_QR_POLL_INTERVAL_MS = 2_000;
64
+ /** Expired QR codes replaced without being asked, per flow; then the flow waits for the person. */
65
+ export const ICQQ_QR_AUTO_REFRESH_LIMIT = 2;
66
+
67
+ export interface IcqqLoginDriverOptions {
68
+ readonly assist: LoginAssist;
69
+ readonly client: IcqqLoginClient;
70
+ readonly endpointKey: string;
71
+ readonly owner: object;
72
+ readonly signal?: AbortSignal;
73
+ readonly endpointIdentity?: LoginAssistEndpointIdentity;
74
+ readonly logger: Logger;
75
+ readonly locale?: Locale;
76
+ readonly qrPollIntervalMs?: number;
77
+ }
78
+
79
+ export interface IcqqLoginErrorResult {
80
+ readonly mapping: IcqqLoginErrorMapping;
81
+ /** The attempt had raised a challenge (the error is a verification result). */
82
+ readonly hadChallenge: boolean;
83
+ /** `code_invalid` on a challenge that takes another answer: the challenge was opened again. */
84
+ readonly retrying: boolean;
85
+ }
86
+
87
+ export class IcqqLoginDriver {
88
+ readonly #o: IcqqLoginDriverOptions;
89
+ #flow?: LoginFlow;
90
+ /** Invalidates the running challenge step when a newer event takes over. */
91
+ #token = 0;
92
+ #running = false;
93
+ /**
94
+ * Last challenge of this attempt. `nt` is fixed when a slider arrives: the SDK clears
95
+ * `sig.nt_captcha` once a ticket is submitted, so it is never recomputed.
96
+ */
97
+ #last?: { kind: IcqqChallengeKind; event: Record<string, unknown>; nt?: boolean };
98
+ #qrAutoRefreshes = 0;
99
+ #pollTimer?: ReturnType<typeof setTimeout>;
100
+ #smsSentAt = 0;
101
+
102
+ constructor(options: IcqqLoginDriverOptions) {
103
+ this.#o = options;
104
+ }
105
+
106
+ /** The live flow of this attempt, if any. */
107
+ get flow(): LoginFlow | undefined {
108
+ return this.#flow && isLive(this.#flow) ? this.#flow : undefined;
109
+ }
110
+
111
+ /** Kind of the last challenge this attempt raised. */
112
+ get lastChallenge(): IcqqChallengeKind | undefined {
113
+ return this.#last?.kind;
114
+ }
115
+
116
+ /**
117
+ * A person is (or is about to be) answering a challenge of this account: automatic relogin
118
+ * must wait. Covers the step being driven and any active challenge of this Endpoint key.
119
+ */
120
+ get busy(): boolean {
121
+ return this.#running || this.#o.assist.hasActiveChallenge(this.#o.endpointKey, 'icqq');
122
+ }
123
+
124
+ /** The attempt ended in a state that waits for the person: failed, or an expired challenge. */
125
+ get awaitingPerson(): boolean {
126
+ const state = this.#flow?.snapshot().state;
127
+ return state === 'failed' || state === 'expired';
128
+ }
129
+
130
+ /** Start a new attempt (supersedes the previous flow of this Endpoint instance). */
131
+ begin(title: string | null = t(this.#o.locale, 'loginConnecting')): LoginFlow | undefined {
132
+ this.invalidate();
133
+ if (this.#o.signal?.aborted) return undefined;
134
+ try {
135
+ this.#flow = this.#o.assist.beginFlow({
136
+ adapter: 'icqq', endpointKey: this.#o.endpointKey, owner: this.#o.owner,
137
+ ...(this.#o.signal ? { signal: this.#o.signal } : {}),
138
+ ...(this.#o.endpointIdentity ? { endpointIdentity: this.#o.endpointIdentity } : {}),
139
+ });
140
+ } catch {
141
+ return undefined;
142
+ }
143
+ this.#last = undefined;
144
+ this.#qrAutoRefreshes = 0;
145
+ // #smsSentAt survives a new attempt: QQ's SMS limit is per account, not per login flow.
146
+ if (title) this.#flow.step('connect', title);
147
+ return this.#flow;
148
+ }
149
+
150
+ /** Another login call inside the current attempt (automatic retry); begins one when none is live. */
151
+ connect(title: string): void {
152
+ const flow = this.flow;
153
+ if (flow) flow.step('connect', title);
154
+ else this.begin(title);
155
+ }
156
+
157
+ /** Timeline mark (e.g. a scheduled retry); begins an attempt when none is live. */
158
+ info(text: string): void {
159
+ (this.flow ?? this.begin(null))?.step('info', text);
160
+ }
161
+
162
+ outcome(outcome: LoginOutcome): void {
163
+ (this.flow ?? this.begin(null))?.outcome(outcome);
164
+ }
165
+
166
+ fail(code: LoginErrorCode, message?: string, next?: LoginNextAction): void {
167
+ this.invalidate();
168
+ (this.flow ?? this.begin(null))?.fail(code, message, next);
169
+ }
170
+
171
+ online(): void {
172
+ this.invalidate();
173
+ this.#flow?.online();
174
+ this.#last = undefined;
175
+ }
176
+
177
+ /** Stop driving the current step (a newer event, online, error or stop took over). */
178
+ invalidate(): void {
179
+ this.#token += 1;
180
+ this.#running = false;
181
+ this.#stopPoll();
182
+ }
183
+
184
+ dispose(): void {
185
+ this.invalidate();
186
+ this.#flow = undefined;
187
+ }
188
+
189
+ /** Open a challenge for an SDK event and drive it. */
190
+ challenge(kind: IcqqChallengeKind, event: Record<string, unknown>): void {
191
+ this.#open(kind, event, kind === 'slider' ? isNtCaptcha(this.#o.client) : undefined);
192
+ }
193
+
194
+ #open(kind: IcqqChallengeKind, event: Record<string, unknown>, nt: boolean | undefined): void {
195
+ if (this.#o.signal?.aborted) return;
196
+ const flow = this.flow ?? this.begin(null);
197
+ if (!flow) return;
198
+ this.invalidate();
199
+ const token = this.#token;
200
+ this.#running = true;
201
+ this.#last = { kind, event, ...(nt !== undefined ? { nt } : {}) };
202
+ this.#o.logger.info(formatCompact({
203
+ op: 'login_assist_challenge',
204
+ endpoint: this.#o.endpointKey,
205
+ type: kind,
206
+ // The link carries a session id (sid / sig): only its host is logged.
207
+ ...(icqqLinkHost(event.url) ? { host: icqqLinkHost(event.url) } : {}),
208
+ }));
209
+ void this.#drive(kind, event, token, nt === true).catch((error: unknown) => {
210
+ if (token !== this.#token) return;
211
+ const message = error instanceof Error ? error.message : String(error);
212
+ this.#o.logger.warn(formatCompact({ op: 'login_assist_failed', endpoint: this.#o.endpointKey, type: kind, error: message }));
213
+ const mapped = mapIcqqLoginError({ message }, this.#o.locale);
214
+ this.#flow?.fail(mapped.code, t(this.#o.locale, 'loginSdkCallFailed', { error: sanitizeIcqqLoginMessage(message) }), mapped.next);
215
+ }).finally(() => {
216
+ if (token === this.#token) this.#running = false;
217
+ });
218
+ }
219
+
220
+ /**
221
+ * `system.login.error`: a wrong code / ticket re-opens the same challenge (`retry_input`);
222
+ * otherwise the caller decides between failing the flow and an automatic retry.
223
+ */
224
+ loginError(event: unknown): IcqqLoginErrorResult {
225
+ const mapping = mapIcqqLoginError(event, this.#o.locale, this.#o.endpointKey);
226
+ const last = this.#last;
227
+ this.invalidate();
228
+ if (mapping.code === 'code_invalid' && last?.kind === 'slider' && last.nt) {
229
+ // The NT captcha is consumed by the submit (the SDK cleared `sig.nt_captcha`): the same link
230
+ // cannot take another ticket. Log in again for a fresh slider.
231
+ this.outcome({ result: 'rejected', code: 'code_invalid', message: mapping.message, next: 'wait' });
232
+ this.#running = true;
233
+ const token = this.#token;
234
+ void Promise.resolve().then(() => this.#o.client.login()).catch((error: unknown) => {
235
+ if (token !== this.#token) return;
236
+ this.#o.logger.warn(formatCompact({ op: 'login_assist_slider_retry_failed', endpoint: this.#o.endpointKey,
237
+ error: error instanceof Error ? error.message : String(error) }));
238
+ this.fail('code_invalid', mapping.message, 'relogin');
239
+ }).finally(() => { if (token === this.#token) this.#running = false; });
240
+ return { mapping, hadChallenge: true, retrying: true };
241
+ }
242
+ if (mapping.code === 'code_invalid' && last && (last.kind === 'sms' || last.kind === 'slider')) {
243
+ this.outcome({ result: 'rejected', code: 'code_invalid', message: mapping.message, next: 'retry_input' });
244
+ this.#open(last.kind, last.event, last.nt);
245
+ return { mapping, hadChallenge: true, retrying: true };
246
+ }
247
+ return { mapping, hadChallenge: !!last, retrying: false };
248
+ }
249
+
250
+ /**
251
+ * `internal.error.qrcode` after a QR login call (the SDK's second scan query failed). An expired
252
+ * or cancelled code is replaced by the SDK itself; anything else shows the same QR code again.
253
+ */
254
+ qrcodeError(code: unknown, message: unknown): void {
255
+ const last = this.#last;
256
+ const flow = this.flow;
257
+ if (!flow || last?.kind !== 'qrcode' || flow.snapshot().activeTaskId) return;
258
+ const text = sanitizeIcqqLoginMessage(message) || t(this.#o.locale, 'qrExpired');
259
+ if (code === ICQQ_QR_RESULT.timeout || code === ICQQ_QR_RESULT.canceled) {
260
+ this.invalidate();
261
+ flow.outcome({ result: 'expired', code: 'challenge_expired', message: text, next: 'refresh_challenge' });
262
+ return;
263
+ }
264
+ const known = code === ICQQ_QR_RESULT.waitingForScan || code === ICQQ_QR_RESULT.waitingForConfirm;
265
+ flow.outcome({ result: 'rejected', ...(known ? {} : { code: 'unknown' as const }), message: text, next: 'retry_input' });
266
+ this.#open('qrcode', last.event, undefined);
267
+ }
268
+
269
+ /**
270
+ * Refresh the current challenge. Only a QR code can be refreshed in place (a new QR code from the
271
+ * SDK); returns false when the caller should log in again instead.
272
+ */
273
+ async refresh(): Promise<boolean> {
274
+ const client = this.#o.client;
275
+ if (this.#last?.kind !== 'qrcode' || !this.flow || typeof client.fetchQrcode !== 'function') return false;
276
+ this.#flow!.step('info', t(this.#o.locale, 'qrRefreshing'));
277
+ await client.fetchQrcode();
278
+ return true;
279
+ }
280
+
281
+ // --------------------------------------------------------------------------
282
+
283
+ async #drive(kind: IcqqChallengeKind, event: Record<string, unknown>, token: number, nt: boolean): Promise<void> {
284
+ switch (kind) {
285
+ case 'qrcode': return this.#driveQr(event, token);
286
+ case 'slider': return this.#driveSlider(event, token, nt);
287
+ case 'device': return this.#driveDevice(event, token);
288
+ case 'sms': return this.#driveSms(event, token);
289
+ case 'auth': return this.#driveAuth(event, token);
290
+ }
291
+ }
292
+
293
+ /** Open a challenge; undefined when it was replaced, cancelled, expired or the Endpoint retired. */
294
+ async #ask(
295
+ token: number,
296
+ descriptor: LoginChallengeDescriptor,
297
+ onOpen?: (taskId: string) => void,
298
+ ): Promise<LoginChallengeAnswer | undefined> {
299
+ const flow = this.#flow;
300
+ if (!flow || token !== this.#token || this.#o.signal?.aborted) return undefined;
301
+ const pending = flow.challenge(descriptor);
302
+ const taskId = flow.snapshot().activeTaskId;
303
+ if (taskId) onOpen?.(taskId);
304
+ try {
305
+ const answer = await pending;
306
+ if (token !== this.#token || this.#o.signal?.aborted) return undefined;
307
+ return answer;
308
+ } catch (error) {
309
+ if (!(error instanceof LoginChallengeReplacedError) && token === this.#token) {
310
+ this.#o.logger.info(formatCompact({
311
+ op: 'login_assist_challenge_ended',
312
+ endpoint: this.#o.endpointKey,
313
+ type: descriptor.kind,
314
+ reason: error instanceof Error ? error.message : String(error),
58
315
  }));
59
316
  }
317
+ return undefined;
318
+ } finally {
319
+ // A newer challenge owns the poll timer once the token moved on.
320
+ if (token === this.#token) this.#stopPoll();
60
321
  }
322
+ }
323
+
324
+ #current(token: number): LoginFlow | undefined {
325
+ return token === this.#token && !this.#o.signal?.aborted ? this.#flow : undefined;
326
+ }
327
+
328
+ // --- qrcode ---------------------------------------------------------------
61
329
 
62
- const value = await assist.waitForInput(adapter, endpointKey, step, payload, { owner, signal: input.signal, endpointIdentity: input.endpointIdentity });
63
- input.signal?.throwIfAborted();
64
- const text = normalizeSubmitValue(value);
65
-
66
- switch (step) {
67
- case 'qrcode':
68
- case 'auth':
69
- await client.login();
70
- break;
71
- case 'slider':
72
- await client.submitSlider(text);
73
- break;
74
- case 'device':
75
- case 'sms':
76
- await client.submitSmsCode(text);
77
- break;
78
- default:
79
- await client.login();
80
- break;
330
+ async #driveQr(event: Record<string, unknown>, token: number): Promise<void> {
331
+ let scanned = false;
332
+ const answer = await this.#ask(token, qrcodeDescriptor({ image: bufferToDataUrl(event.image) }, this.#o.locale),
333
+ (taskId) => this.#startQrPoll(token, taskId, () => { scanned = true; }));
334
+ if (!answer) return;
335
+ const client = this.#o.client;
336
+ const canQuery = typeof client.queryQrcodeResult === 'function' && typeof client.qrcodeLogin === 'function';
337
+ if (canQuery && !scanned) {
338
+ let retcode: number;
339
+ try {
340
+ retcode = (await client.queryQrcodeResult!()).retcode;
341
+ } catch {
342
+ retcode = -1;
343
+ }
344
+ const flow = this.#current(token);
345
+ if (!flow) return;
346
+ if (retcode === ICQQ_QR_RESULT.waitingForScan || retcode === ICQQ_QR_RESULT.waitingForConfirm || retcode < 0) {
347
+ const message = retcode === ICQQ_QR_RESULT.waitingForScan ? t(this.#o.locale, 'qrNotScanned')
348
+ : retcode === ICQQ_QR_RESULT.waitingForConfirm ? t(this.#o.locale, 'qrScanned')
349
+ : t(this.#o.locale, 'loginErrorNetwork');
350
+ flow.outcome({ result: 'rejected', ...(retcode < 0 ? { code: 'network' as const } : {}), message, next: 'retry_input' });
351
+ return this.#driveQr(event, token);
352
+ }
353
+ if (retcode !== ICQQ_QR_RESULT.ok) {
354
+ flow.outcome({ result: 'expired', code: 'challenge_expired', message: t(this.#o.locale, 'qrExpired'), next: 'refresh_challenge' });
355
+ await this.#newQr();
356
+ return;
357
+ }
81
358
  }
82
- } catch (error) {
83
- logger.warn(formatCompact({
84
- op: 'login_assist_failed',
85
- endpoint: endpointKey,
86
- type: step,
87
- error: error instanceof Error ? error.message : String(error),
88
- }));
359
+ if (!this.#current(token)) return;
360
+ this.#flow!.verifying(t(this.#o.locale, 'loginVerifying'));
361
+ if (canQuery) await client.qrcodeLogin!();
362
+ else await client.login();
363
+ }
364
+
365
+ async #newQr(): Promise<void> {
366
+ const client = this.#o.client;
367
+ if (typeof client.fetchQrcode === 'function') await client.fetchQrcode();
368
+ else await client.login();
369
+ }
370
+
371
+ /** Follow the scan while the QR code is shown: scanned → info step; confirmed → continue. */
372
+ #startQrPoll(token: number, taskId: string, confirmed: () => void): void {
373
+ const client = this.#o.client;
374
+ if (typeof client.queryQrcodeResult !== 'function' || typeof client.qrcodeLogin !== 'function') return;
375
+ this.#stopPoll();
376
+ let announced = false;
377
+ const active = () => token === this.#token && !this.#o.signal?.aborted && this.#flow?.snapshot().activeTaskId === taskId;
378
+ const schedule = () => {
379
+ this.#pollTimer = setTimeout(() => { void tick(); }, this.#o.qrPollIntervalMs ?? ICQQ_QR_POLL_INTERVAL_MS);
380
+ this.#pollTimer.unref?.();
381
+ };
382
+ const tick = async () => {
383
+ this.#pollTimer = undefined;
384
+ if (!active()) return;
385
+ let retcode: number | undefined;
386
+ try {
387
+ retcode = (await client.queryQrcodeResult!()).retcode;
388
+ } catch {
389
+ retcode = undefined;
390
+ }
391
+ if (!active()) return;
392
+ const flow = this.#flow!;
393
+ if (retcode === ICQQ_QR_RESULT.ok) {
394
+ confirmed();
395
+ try {
396
+ this.#o.assist.submit(taskId, { action: 'confirm', values: {} }, this.#o.endpointIdentity);
397
+ } catch { /* the challenge ended meanwhile */ }
398
+ return;
399
+ }
400
+ if (retcode === ICQQ_QR_RESULT.waitingForConfirm && !announced) {
401
+ announced = true;
402
+ flow.step('info', t(this.#o.locale, 'qrScanned'));
403
+ }
404
+ if (retcode === ICQQ_QR_RESULT.timeout || retcode === ICQQ_QR_RESULT.canceled) {
405
+ if (this.#qrAutoRefreshes < ICQQ_QR_AUTO_REFRESH_LIMIT) {
406
+ this.#qrAutoRefreshes += 1;
407
+ flow.step('info', t(this.#o.locale, 'qrExpiredRefreshing'));
408
+ void this.#newQr().catch((error: unknown) => {
409
+ this.#o.logger.warn(formatCompact({ op: 'login_assist_qr_refresh_failed', endpoint: this.#o.endpointKey, error: error instanceof Error ? error.message : String(error) }));
410
+ });
411
+ return;
412
+ }
413
+ flow.outcome({ result: 'expired', code: 'challenge_expired', message: t(this.#o.locale, 'qrExpired'), next: 'refresh_challenge' });
414
+ flow.end('expired');
415
+ return;
416
+ }
417
+ schedule();
418
+ };
419
+ schedule();
89
420
  }
90
- }
91
421
 
92
- function buildPayload(step: LoginAssistType, event: unknown, locale?: Locale): PendingLoginTaskPayload {
93
- const raw = event && typeof event === 'object' ? event as Record<string, unknown> : {};
94
- switch (step) {
95
- case 'qrcode':
96
- return {
97
- message: t(locale, 'loginQr'),
98
- image: bufferToDataUrl(raw.image),
99
- };
100
- case 'slider':
101
- return {
102
- message: t(locale, 'loginSlider'),
103
- url: typeof raw.url === 'string' ? raw.url : undefined,
104
- };
105
- case 'device':
106
- return {
107
- message: t(locale, 'loginSms'),
108
- url: typeof raw.url === 'string' ? raw.url : undefined,
109
- phone: raw.phone,
110
- };
111
- case 'auth':
112
- return {
113
- message: t(locale, 'loginAuth'),
114
- url: typeof raw.url === 'string' ? raw.url : undefined,
115
- device: raw.device,
116
- };
117
- default:
118
- return { message: t(locale, 'loginOther', { step }) };
422
+ #stopPoll(): void {
423
+ if (this.#pollTimer) clearTimeout(this.#pollTimer);
424
+ this.#pollTimer = undefined;
425
+ }
426
+
427
+ // --- slider ---------------------------------------------------------------
428
+
429
+ async #driveSlider(event: Record<string, unknown>, token: number, nt: boolean): Promise<void> {
430
+ const url = typeof event.url === 'string' && event.url ? event.url : undefined;
431
+ for (;;) {
432
+ const answer = await this.#ask(token, sliderDescriptor(url, this.#o.locale));
433
+ if (!answer) return;
434
+ const flow = this.#flow!;
435
+ const ticket = answer.values.input?.trim();
436
+ const randstr = answer.values.randstr?.trim();
437
+ if (!ticket || (nt && !randstr)) {
438
+ flow.outcome({
439
+ result: 'rejected', code: 'code_invalid', next: 'retry_input',
440
+ message: t(this.#o.locale, ticket ? 'sliderRandstrMissing' : 'loginErrorCodeInvalid'),
441
+ });
442
+ continue;
443
+ }
444
+ flow.verifying(t(this.#o.locale, 'loginVerifyingSlider'));
445
+ await this.#o.client.submitSlider(icqqSliderSubmission({ ticket, randstr }, nt));
446
+ return;
447
+ }
448
+ }
449
+
450
+ // --- device / sms ---------------------------------------------------------
451
+
452
+ async #driveDevice(event: Record<string, unknown>, token: number): Promise<void> {
453
+ const url = typeof event.url === 'string' && event.url ? event.url : undefined;
454
+ const phone = typeof event.phone === 'string' && event.phone ? event.phone : undefined;
455
+ for (;;) {
456
+ const answer = await this.#ask(token, deviceDescriptor({ url, phone }, this.#o.locale));
457
+ if (!answer) return;
458
+ if (answer.actionId === 'sms') {
459
+ if (!await this.#sendSms(token)) continue;
460
+ if (!this.#current(token)) return;
461
+ this.#last = { kind: 'sms', event };
462
+ return this.#driveSms(event, token);
463
+ }
464
+ this.#flow!.verifying(t(this.#o.locale, 'loginVerifyingDevice'));
465
+ await this.#o.client.login();
466
+ return;
467
+ }
468
+ }
469
+
470
+ async #driveSms(event: Record<string, unknown>, token: number): Promise<void> {
471
+ const phone = typeof event.phone === 'string' && event.phone ? event.phone : undefined;
472
+ for (;;) {
473
+ const answer = await this.#ask(token, smsDescriptor({ phone }, this.#o.locale));
474
+ if (!answer) return;
475
+ if (answer.actionId === 'resend') {
476
+ if (await this.#sendSms(token)) this.#current(token)?.step('info', t(this.#o.locale, 'smsResent'));
477
+ continue;
478
+ }
479
+ this.#flow!.verifying(t(this.#o.locale, 'loginVerifyingSms'));
480
+ await this.#o.client.submitSmsCode((answer.values.input ?? '').trim());
481
+ return;
482
+ }
483
+ }
484
+
485
+ /** Request an SMS code; a failure (or the cooldown) is reported on the step and returns false. */
486
+ async #sendSms(token: number): Promise<boolean> {
487
+ const waitMs = this.#smsSentAt + ICQQ_SMS_COOLDOWN_MS - Date.now();
488
+ if (this.#smsSentAt > 0 && waitMs > 0) {
489
+ this.#flow?.outcome({
490
+ result: 'rejected', code: 'rate_limited', next: 'wait', retryAfterMs: waitMs,
491
+ message: t(this.#o.locale, 'loginErrorRateLimited'),
492
+ });
493
+ return false;
494
+ }
495
+ try {
496
+ await this.#o.client.sendSmsCode();
497
+ this.#smsSentAt = Date.now();
498
+ return true;
499
+ } catch (error) {
500
+ const message = error instanceof Error ? error.message : String(error);
501
+ this.#o.logger.warn(formatCompact({ op: 'login_assist_send_sms_failed', endpoint: this.#o.endpointKey, error: message }));
502
+ const code = classifyIcqqSmsFailure(error);
503
+ this.#current(token)?.outcome({
504
+ result: 'rejected', code, next: code === 'rate_limited' ? 'wait' : 'retry_input',
505
+ message: t(this.#o.locale, 'smsSendFailed', { error: sanitizeIcqqLoginMessage(message) || t(this.#o.locale, 'loginErrorUnknown') }),
506
+ });
507
+ return false;
508
+ }
509
+ }
510
+
511
+ // --- auth -----------------------------------------------------------------
512
+
513
+ async #driveAuth(event: Record<string, unknown>, token: number): Promise<void> {
514
+ const url = typeof event.url === 'string' && event.url ? event.url : undefined;
515
+ const answer = await this.#ask(token, authDescriptor({ url, device: event.device }, this.#o.locale));
516
+ if (!answer) return;
517
+ this.#flow!.verifying(t(this.#o.locale, 'loginVerifyingAuth'));
518
+ await this.#o.client.login();
119
519
  }
120
520
  }
121
521
 
122
- function normalizeSubmitValue(value: string | Record<string, unknown>): string {
123
- if (typeof value === 'string') return value.trim();
124
- if (typeof value.ticket === 'string') return value.ticket.trim();
125
- if (typeof value.code === 'string') return value.code.trim();
126
- if (typeof value.value === 'string') return value.value.trim();
127
- return JSON.stringify(value);
522
+ function isLive(flow: LoginFlow): boolean {
523
+ const state = flow.snapshot().state;
524
+ return state === 'active' || state === 'expired';
128
525
  }
129
526
 
130
- function bufferToDataUrl(image: unknown): string | undefined {
131
- if (Buffer.isBuffer(image)) {
132
- return `data:image/png;base64,${image.toString('base64')}`;
133
- }
134
- if (image instanceof Uint8Array) {
135
- return `data:image/png;base64,${Buffer.from(image).toString('base64')}`;
527
+ /**
528
+ * The SDK's own rule (`submitSlider`): a truthy `sig.nt_captcha` means the NT path, which splits
529
+ * `ticket,randstr`; otherwise the legacy wtlogin path puts the raw text into TLV 0x193, so it must
530
+ * get the ticket alone. Read once, when the slider arrives (submitting clears it).
531
+ */
532
+ export function isNtCaptcha(client: Pick<IcqqLoginClient, 'sig'>): boolean {
533
+ const sig = client.sig;
534
+ return !!sig && typeof sig === 'object' && !!(sig as { nt_captcha?: unknown }).nt_captcha;
535
+ }
536
+
537
+ /** What `submitSlider` receives: `ticket,randstr` on the NT path, the ticket alone otherwise. */
538
+ export function icqqSliderSubmission(value: { ticket: string; randstr?: string }, nt: boolean): string {
539
+ return nt && value.randstr ? `${value.ticket},${value.randstr}` : value.ticket;
540
+ }
541
+
542
+ /** Host name of an http(s) link (what logs may show of a verification link), or undefined. */
543
+ export function icqqLinkHost(url: unknown): string | undefined {
544
+ if (typeof url !== 'string' || !url) return undefined;
545
+ try {
546
+ const parsed = new URL(url);
547
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.hostname : undefined;
548
+ } catch {
549
+ return undefined;
136
550
  }
551
+ }
552
+
553
+ export function bufferToDataUrl(image: unknown): string | undefined {
554
+ if (Buffer.isBuffer(image)) return `data:image/png;base64,${image.toString('base64')}`;
555
+ if (image instanceof Uint8Array) return `data:image/png;base64,${Buffer.from(image).toString('base64')}`;
137
556
  if (typeof image === 'string' && image.trim()) return image.trim();
138
557
  return undefined;
139
558
  }