@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.
- package/CHANGELOG.md +32 -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
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { adapterFeatureId, isAdapterIndex } from 'zhin.js/adapter';
|
|
7
7
|
import type { FeatureId, PluginId } from 'zhin.js';
|
|
8
8
|
import { IcqqAdapter } from './adapter.js';
|
|
9
|
-
import type { PendingLoginTask } from '@zhin.js/core';
|
|
9
|
+
import type { LoginFlowSnapshot, PendingLoginTask } from '@zhin.js/core';
|
|
10
10
|
|
|
11
11
|
export interface IcqqLoginControlState {
|
|
12
12
|
readonly endpointId: string;
|
|
@@ -19,8 +19,11 @@ export interface IcqqLoginControl {
|
|
|
19
19
|
readonly endpointId: string;
|
|
20
20
|
state(): IcqqLoginControlState;
|
|
21
21
|
pending(): readonly PendingLoginTask[];
|
|
22
|
+
/** Latest login flow of this incarnation (ADR 0058), including the active challenge. */
|
|
23
|
+
flow(): LoginFlowSnapshot | undefined;
|
|
22
24
|
/** Force a login attempt; rejects when the endpoint is stopped or the SDK login fails. */
|
|
23
25
|
relogin(): Promise<void>;
|
|
26
|
+
/** Answer a challenge; throws `LoginSubmissionError` when the answer does not fit it. */
|
|
24
27
|
submit(taskId: string, value: string | Record<string, unknown>): boolean;
|
|
25
28
|
cancel(taskId: string, reason?: string): boolean;
|
|
26
29
|
}
|
|
@@ -0,0 +1,200 @@
|
|
|
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, LoginHelpStep } from '@zhin.js/core';
|
|
10
|
+
import { messages as t } from './i18n.js';
|
|
11
|
+
|
|
12
|
+
/** Challenge ids are stable per kind so the Console can keep UI state across re-opened challenges. */
|
|
13
|
+
export const ICQQ_CHALLENGE_IDS = Object.freeze({
|
|
14
|
+
qrcode: 'icqq-qrcode', slider: 'icqq-slider', device: 'icqq-device', sms: 'icqq-sms', auth: 'icqq-auth',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/** SMS resend cooldown (QQ refuses a second code within a minute). */
|
|
18
|
+
export const ICQQ_SMS_COOLDOWN_MS = 60_000;
|
|
19
|
+
|
|
20
|
+
/** Page the `auth` helper script runs on (QQ's "send verification code" page). */
|
|
21
|
+
export const ICQQ_AUTH_SCRIPT_HOST = 'accounts.qq.com';
|
|
22
|
+
export const ICQQ_AUTH_SCRIPT_PATH = '/login/attack';
|
|
23
|
+
|
|
24
|
+
export function qrcodeDescriptor(input: { image?: string }, locale?: Locale): LoginChallengeDescriptor {
|
|
25
|
+
return {
|
|
26
|
+
id: ICQQ_CHALLENGE_IDS.qrcode,
|
|
27
|
+
kind: 'qrcode',
|
|
28
|
+
title: t(locale, 'qrTitle'),
|
|
29
|
+
help: {
|
|
30
|
+
short: t(locale, 'qrShort'),
|
|
31
|
+
steps: [t(locale, 'qrStepScan'), t(locale, 'qrStepConfirm'), t(locale, 'qrStepContinue')],
|
|
32
|
+
},
|
|
33
|
+
media: { ...(input.image ? { qrImage: input.image } : {}), refreshable: true },
|
|
34
|
+
actions: [{ id: 'confirm', label: t(locale, 'qrConfirm'), variant: 'primary' }],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function sliderDescriptor(url: string | undefined, locale?: Locale): LoginChallengeDescriptor {
|
|
39
|
+
const query = url ? sliderCaptchaQuery(url) : undefined;
|
|
40
|
+
return {
|
|
41
|
+
id: ICQQ_CHALLENGE_IDS.slider,
|
|
42
|
+
kind: 'slider',
|
|
43
|
+
title: t(locale, 'sliderTitle'),
|
|
44
|
+
help: {
|
|
45
|
+
short: t(locale, 'sliderShort'),
|
|
46
|
+
steps: [
|
|
47
|
+
{ text: t(locale, 'sliderStepOpen'), ...(url ? { blockId: 'slider-link', doneWhen: 'opened' as const } : {}) },
|
|
48
|
+
t(locale, 'sliderStepDevtools'),
|
|
49
|
+
t(locale, 'sliderStepCopy'),
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
...(url ? { blocks: [{ kind: 'link' as const, id: 'slider-link', label: t(locale, 'sliderLink'), url }] } : {}),
|
|
53
|
+
...(query ? { media: { captcha: { provider: 'tencent' as const, query } } } : {}),
|
|
54
|
+
input: { kind: 'ticket', label: t(locale, 'sliderInput'), extract: 'ticket-json-or-url' },
|
|
55
|
+
actions: [{ id: 'submit', label: t(locale, 'loginSubmit'), variant: 'primary', requiresInput: true }],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function deviceDescriptor(input: { url?: string; phone?: string }, locale?: Locale): LoginChallengeDescriptor {
|
|
60
|
+
const steps: LoginHelpStep[] = [
|
|
61
|
+
input.url ? { text: t(locale, 'deviceStepScan'), blockId: 'verify-link', doneWhen: 'opened' } : t(locale, 'deviceStepNoLink'),
|
|
62
|
+
t(locale, 'deviceStepVerify'),
|
|
63
|
+
t(locale, 'deviceStepContinue'),
|
|
64
|
+
];
|
|
65
|
+
if (input.phone) steps.push(t(locale, 'deviceStepSms'));
|
|
66
|
+
return {
|
|
67
|
+
id: ICQQ_CHALLENGE_IDS.device,
|
|
68
|
+
kind: 'device',
|
|
69
|
+
title: t(locale, 'deviceTitle'),
|
|
70
|
+
help: { short: t(locale, 'deviceShort'), steps },
|
|
71
|
+
...(input.url ? { blocks: [{ kind: 'link' as const, id: 'verify-link', label: t(locale, 'deviceLink'), url: input.url, showQr: true }] } : {}),
|
|
72
|
+
actions: [
|
|
73
|
+
{ id: 'continue', label: t(locale, 'deviceContinue'), variant: 'primary' },
|
|
74
|
+
// The SDK only reports a phone when SMS verification is possible for this lock.
|
|
75
|
+
...(input.phone ? [{ id: 'sms', label: t(locale, 'deviceSms'), variant: 'secondary' as const }] : []),
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function smsDescriptor(input: { phone?: string }, locale?: Locale): LoginChallengeDescriptor {
|
|
81
|
+
return {
|
|
82
|
+
id: ICQQ_CHALLENGE_IDS.sms,
|
|
83
|
+
kind: 'sms',
|
|
84
|
+
title: t(locale, 'smsTitle'),
|
|
85
|
+
help: { short: t(locale, 'smsShort', { phone: input.phone || t(locale, 'smsPhoneFallback') }) },
|
|
86
|
+
input: { kind: 'code', label: t(locale, 'smsInput'), length: 6, pattern: '^\\d{6}$', patternHint: t(locale, 'smsHint') },
|
|
87
|
+
actions: [
|
|
88
|
+
{ id: 'submit', label: t(locale, 'loginSubmit'), variant: 'primary', requiresInput: true },
|
|
89
|
+
{ id: 'resend', label: t(locale, 'smsResend'), variant: 'secondary', cooldownMs: ICQQ_SMS_COOLDOWN_MS },
|
|
90
|
+
],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function authDescriptor(input: { url?: string; device?: unknown }, locale?: Locale): LoginChallengeDescriptor {
|
|
95
|
+
const deviceJson = formatDeviceJson(input.device);
|
|
96
|
+
const hasLink = !!input.url;
|
|
97
|
+
const steps: LoginHelpStep[] = [];
|
|
98
|
+
if (deviceJson) steps.push({ text: t(locale, 'authStepCopy'), blockId: 'device-json', doneWhen: 'copied' });
|
|
99
|
+
steps.push(hasLink ? { text: t(locale, 'authStepOpen'), blockId: 'verify-link', doneWhen: 'opened' } : t(locale, 'authStepOpen'));
|
|
100
|
+
steps.push(deviceJson ? { text: t(locale, 'authStepScript'), blockId: 'helper-script', doneWhen: 'copied' } : t(locale, 'authStepVerify'));
|
|
101
|
+
steps.push({ text: t(locale, 'authStepContinue'), doneWhen: 'manual' });
|
|
102
|
+
return {
|
|
103
|
+
id: ICQQ_CHALLENGE_IDS.auth,
|
|
104
|
+
kind: 'auth',
|
|
105
|
+
title: t(locale, 'authTitle'),
|
|
106
|
+
help: { short: t(locale, 'authShort'), steps, ...(deviceJson ? { warning: t(locale, 'authWarning') } : {}) },
|
|
107
|
+
blocks: [
|
|
108
|
+
...(deviceJson ? [{
|
|
109
|
+
kind: 'copy' as const, id: 'device-json', label: t(locale, 'authJson'), content: deviceJson, format: 'json' as const,
|
|
110
|
+
sensitive: true, ...(hasLink ? { thenOpen: 'verify-link' } : {}),
|
|
111
|
+
}] : []),
|
|
112
|
+
...(hasLink ? [{ kind: 'link' as const, id: 'verify-link', label: t(locale, 'authLink'), url: input.url!, showQr: true }] : []),
|
|
113
|
+
...(deviceJson ? [{
|
|
114
|
+
kind: 'copy' as const, id: 'helper-script', label: t(locale, 'authScript'), content: icqqAuthHelperScript(locale),
|
|
115
|
+
format: 'script' as const, collapsed: true, collapsedLabel: t(locale, 'authScriptCollapsed'), bookmarklet: true,
|
|
116
|
+
}] : []),
|
|
117
|
+
],
|
|
118
|
+
actions: [{ id: 'continue', label: t(locale, 'authContinue'), variant: 'primary' }],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Query string of the slider URL (for the hosted captcha helper), or undefined for a non-http URL. */
|
|
123
|
+
export function sliderCaptchaQuery(url: string): string | undefined {
|
|
124
|
+
try {
|
|
125
|
+
const parsed = new URL(url);
|
|
126
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return undefined;
|
|
127
|
+
const query = parsed.search.replace(/^\?/u, '');
|
|
128
|
+
return query || undefined;
|
|
129
|
+
} catch {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Device information of `system.login.auth` as pretty JSON (string fields only), or undefined. */
|
|
135
|
+
export function formatDeviceJson(device: unknown): string | undefined {
|
|
136
|
+
if (!device || typeof device !== 'object' || Array.isArray(device)) return undefined;
|
|
137
|
+
const fields: Record<string, string> = {};
|
|
138
|
+
for (const [key, value] of Object.entries(device as Record<string, unknown>)) {
|
|
139
|
+
if (typeof value === 'string') fields[key] = value;
|
|
140
|
+
else if (typeof value === 'number' && Number.isFinite(value)) fields[key] = String(value);
|
|
141
|
+
}
|
|
142
|
+
if (typeof fields.guid !== 'string' || !fields.guid) return undefined;
|
|
143
|
+
return JSON.stringify(fields, null, 2);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const SCRIPT_TEXT = {
|
|
147
|
+
'zh-CN': {
|
|
148
|
+
wrongPage: '请在 accounts.qq.com/login/attack(发送验证码的页面)运行这段脚本',
|
|
149
|
+
prompt: '粘贴从 Zhin 复制的设备 JSON',
|
|
150
|
+
badJson: '设备 JSON 格式不正确,请重新复制',
|
|
151
|
+
noGuid: '设备 JSON 缺少 guid,请重新复制',
|
|
152
|
+
notReady: '页面还没有加载完成,请刷新后再运行',
|
|
153
|
+
done: '设备信息已写入。请在页面上继续完成验证,完成后回到 Zhin 点「已完成,继续登录」',
|
|
154
|
+
},
|
|
155
|
+
en: {
|
|
156
|
+
wrongPage: 'Run this script on accounts.qq.com/login/attack (the page that sends the verification code)',
|
|
157
|
+
prompt: 'Paste the device JSON copied from Zhin',
|
|
158
|
+
badJson: 'The device JSON is not valid. Copy it again',
|
|
159
|
+
noGuid: 'The device JSON has no guid. Copy it again',
|
|
160
|
+
notReady: 'The page has not finished loading. Reload it and run the script again',
|
|
161
|
+
done: 'Device information set. Finish the verification on the page, then choose “Done, continue logging in” in Zhin',
|
|
162
|
+
},
|
|
163
|
+
} as const;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Static helper script for the `auth` challenge. It runs only on the QQ verification page, asks for
|
|
167
|
+
* the device JSON with `prompt()`, checks it, sets `window.__INITIAL_STATE__.deviceInfo` and reports
|
|
168
|
+
* the result. It never submits anything and contains no device or user data. No `//` comments: the
|
|
169
|
+
* same text becomes a one-line bookmarklet.
|
|
170
|
+
*/
|
|
171
|
+
export function icqqAuthHelperScript(locale?: Locale): string {
|
|
172
|
+
const text = SCRIPT_TEXT[locale === 'en' ? 'en' : 'zh-CN'];
|
|
173
|
+
const s = (value: string) => JSON.stringify(value);
|
|
174
|
+
return [
|
|
175
|
+
'(function () {',
|
|
176
|
+
` if (location.hostname !== ${s(ICQQ_AUTH_SCRIPT_HOST)} || location.pathname !== ${s(ICQQ_AUTH_SCRIPT_PATH)}) {`,
|
|
177
|
+
` alert(${s(text.wrongPage)});`,
|
|
178
|
+
' return;',
|
|
179
|
+
' }',
|
|
180
|
+
` var raw = prompt(${s(text.prompt)});`,
|
|
181
|
+
' if (!raw) return;',
|
|
182
|
+
' var info;',
|
|
183
|
+
' try { info = JSON.parse(raw); } catch (error) {',
|
|
184
|
+
` alert(${s(text.badJson)});`,
|
|
185
|
+
' return;',
|
|
186
|
+
' }',
|
|
187
|
+
" if (!info || typeof info !== 'object' || typeof info.guid !== 'string' || !info.guid) {",
|
|
188
|
+
` alert(${s(text.noGuid)});`,
|
|
189
|
+
' return;',
|
|
190
|
+
' }',
|
|
191
|
+
' var state = window.__INITIAL_STATE__;',
|
|
192
|
+
" if (!state || typeof state !== 'object') {",
|
|
193
|
+
` alert(${s(text.notReady)});`,
|
|
194
|
+
' return;',
|
|
195
|
+
' }',
|
|
196
|
+
' state.deviceInfo = info;',
|
|
197
|
+
` alert(${s(text.done)});`,
|
|
198
|
+
'})();',
|
|
199
|
+
].join('\n');
|
|
200
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ICQQ `system.login.error` → LoginAssist error codes (ADR 0058 §5).
|
|
3
|
+
*
|
|
4
|
+
* One table: exact SDK codes first (`LoginErrorCode` of the legacy wtlogin path, `NTLoginErrorCode`
|
|
5
|
+
* of the NT path), then message patterns for errors the SDK only reports as text. The platform's
|
|
6
|
+
* message is kept only as a sanitized single line (no control characters, no local file paths).
|
|
7
|
+
*/
|
|
8
|
+
import type { Locale } from 'zhin.js/i18n';
|
|
9
|
+
import type { LoginErrorCode, LoginNextAction } from '@zhin.js/core';
|
|
10
|
+
import { messages as t } from './i18n.js';
|
|
11
|
+
|
|
12
|
+
export interface IcqqLoginErrorRule {
|
|
13
|
+
/** Exact SDK error codes. */
|
|
14
|
+
readonly codes?: readonly number[];
|
|
15
|
+
/** Matched against the SDK message when no code rule applies. */
|
|
16
|
+
readonly pattern?: RegExp;
|
|
17
|
+
readonly code: LoginErrorCode;
|
|
18
|
+
readonly next: LoginNextAction;
|
|
19
|
+
/** What the SDK means (documentation). */
|
|
20
|
+
readonly note: string;
|
|
21
|
+
/** Localized message used instead of the SDK text (when the SDK text would mislead). */
|
|
22
|
+
readonly messageKey?: 'loginErrorTokenIncompatible';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Ordered: the first matching rule wins; code rules are checked before message rules. */
|
|
26
|
+
export const ICQQ_LOGIN_ERROR_TABLE: readonly IcqqLoginErrorRule[] = Object.freeze([
|
|
27
|
+
// --- exact codes ---
|
|
28
|
+
{ codes: [1, 140022013, 150022029, 150022032], code: 'credential_invalid', next: 'relogin',
|
|
29
|
+
note: 'WrongPassword / AccountOrPasswordError / MultiplePasswordIncorrect / PasswordModifiedDuringLogin' },
|
|
30
|
+
// The saved token does not fit the protocol; logging in again reads the same file, so only
|
|
31
|
+
// removing it helps.
|
|
32
|
+
{ codes: [123456], code: 'credential_invalid', next: 'none', note: 'token incompatible', messageKey: 'loginErrorTokenIncompatible' },
|
|
33
|
+
{ codes: [140022009], code: 'credential_invalid', next: 'relogin', note: 'RefusePasswordLogin (log in by QR code instead)' },
|
|
34
|
+
{ codes: [163, 150022026, 140022016], code: 'code_invalid', next: 'retry_input', note: 'WrongSmsCode / SmsInvalid / IllegalTicket' },
|
|
35
|
+
{ codes: [162, 150022022, 150022023], code: 'rate_limited', next: 'wait', note: 'TooManySms / TooOften / TooManyTimesToday' },
|
|
36
|
+
{ codes: [40, 235, 140022005, 140022019, 150022021, 150022030, 150022033], code: 'risk_blocked', next: 'none',
|
|
37
|
+
note: 'AccountFrozen / device blacklisted / Frozen / NeedVerifyRealName / BlackAccount / IllegalAccount / LhrEfundFreeze' },
|
|
38
|
+
{ codes: [140022014, 150022035, 150022039], code: 'challenge_expired', next: 'relogin', note: 'ExpireTicket / LongTimeOfNoOperation / CookieExpired' },
|
|
39
|
+
{ codes: [140022002, 140022003, 150022031], code: 'network', next: 'wait', note: 'SystemFailed / TimeoutRetry / InterfaceUnavailable' },
|
|
40
|
+
// --- message patterns (the SDK reuses codes such as 237 for different texts) ---
|
|
41
|
+
{ pattern: /频繁|频率|too (?:many|often)|frequent/iu, code: 'rate_limited', next: 'wait', note: 'too frequent' },
|
|
42
|
+
{ pattern: /冻结|拉黑|封禁|封号|风险|风控|安全原因|frozen|blocked/iu, code: 'risk_blocked', next: 'none', note: 'risk control' },
|
|
43
|
+
// Before the ticket rule: "ticket 已过期" is an expired challenge, not a wrong answer.
|
|
44
|
+
{ pattern: /过期|超时|expired|timed? ?out/iu, code: 'challenge_expired', next: 'relogin', note: 'expired' },
|
|
45
|
+
{ pattern: /验证码(?:错误|不正确|无效)|ticket|wrong code/iu, code: 'code_invalid', next: 'retry_input', note: 'wrong code' },
|
|
46
|
+
{ pattern: /密码|password/iu, code: 'credential_invalid', next: 'relogin', note: 'password' },
|
|
47
|
+
{ pattern: /网络|繁忙|network|busy/iu, code: 'network', next: 'wait', note: 'network' },
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
export interface IcqqLoginErrorMapping {
|
|
51
|
+
readonly code: LoginErrorCode;
|
|
52
|
+
readonly next: LoginNextAction;
|
|
53
|
+
/** Sanitized platform message, or a localized label when the SDK gave none. */
|
|
54
|
+
readonly message: string;
|
|
55
|
+
/** The SDK code, for logs. */
|
|
56
|
+
readonly platformCode?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const LABEL_KEYS = {
|
|
60
|
+
code_invalid: 'loginErrorCodeInvalid',
|
|
61
|
+
rate_limited: 'loginErrorRateLimited',
|
|
62
|
+
challenge_expired: 'loginErrorExpired',
|
|
63
|
+
risk_blocked: 'loginErrorRisk',
|
|
64
|
+
credential_invalid: 'loginErrorCredential',
|
|
65
|
+
network: 'loginErrorNetwork',
|
|
66
|
+
unknown: 'loginErrorUnknown',
|
|
67
|
+
} as const satisfies Record<LoginErrorCode, string>;
|
|
68
|
+
|
|
69
|
+
/** Map one `system.login.error` payload (`{ code, message }`). */
|
|
70
|
+
export function mapIcqqLoginError(event: unknown, locale?: Locale, uin?: string): IcqqLoginErrorMapping {
|
|
71
|
+
const raw = event && typeof event === 'object' ? event as { code?: unknown; message?: unknown } : {};
|
|
72
|
+
const platformCode = typeof raw.code === 'number' && Number.isFinite(raw.code) ? raw.code : undefined;
|
|
73
|
+
const text = typeof raw.message === 'string' ? raw.message : '';
|
|
74
|
+
const rule = ICQQ_LOGIN_ERROR_TABLE.find((item) => platformCode !== undefined && item.codes?.includes(platformCode))
|
|
75
|
+
?? ICQQ_LOGIN_ERROR_TABLE.find((item) => item.pattern && text && item.pattern.test(text));
|
|
76
|
+
const code: LoginErrorCode = rule?.code ?? 'unknown';
|
|
77
|
+
return Object.freeze({
|
|
78
|
+
code,
|
|
79
|
+
next: rule?.next ?? 'relogin',
|
|
80
|
+
message: rule?.messageKey ? t(locale, rule.messageKey, { uin: uin || '<uin>' })
|
|
81
|
+
: sanitizeIcqqLoginMessage(text) || t(locale, LABEL_KEYS[code]),
|
|
82
|
+
...(platformCode !== undefined ? { platformCode } : {}),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Localized label of an error code. */
|
|
87
|
+
export function icqqLoginErrorLabel(code: LoginErrorCode, locale?: Locale): string {
|
|
88
|
+
return t(locale, LABEL_KEYS[code]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Classify a failed `sendSmsCode()`: too frequent → `rate_limited`, anything else `unknown`. */
|
|
92
|
+
export function classifyIcqqSmsFailure(error: unknown): Extract<LoginErrorCode, 'rate_limited' | 'unknown'> {
|
|
93
|
+
const text = error instanceof Error ? error.message : typeof error === 'string' ? error : '';
|
|
94
|
+
const code = error && typeof error === 'object' && typeof (error as { code?: unknown }).code === 'number'
|
|
95
|
+
? (error as { code: number }).code : undefined;
|
|
96
|
+
if (code === 162 || /频繁|频率|上限|too (?:many|often)|frequent|limit/iu.test(text)) return 'rate_limited';
|
|
97
|
+
return 'unknown';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* One line, no control characters, local file paths replaced, at most 200 characters.
|
|
102
|
+
* (The device-blacklist message embeds the absolute path of `device.json`.)
|
|
103
|
+
*/
|
|
104
|
+
export function sanitizeIcqqLoginMessage(value: unknown): string {
|
|
105
|
+
if (typeof value !== 'string') return '';
|
|
106
|
+
// eslint-disable-next-line no-control-regex
|
|
107
|
+
let text = value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/\s+/gu, ' ').trim();
|
|
108
|
+
text = text
|
|
109
|
+
.replace(/(["'“”])(?:[A-Za-z]:)?[\\/][^"'“”]*\1/gu, '$1…$1')
|
|
110
|
+
.replace(/(?:^|(?<=\s))(?:[A-Za-z]:)?(?:[\\/][\w.@-]+){2,}/gu, '…');
|
|
111
|
+
return text.length > 200 ? `${text.slice(0, 199)}…` : text;
|
|
112
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redacting wrapper for the ICQQ SDK's own logger.
|
|
3
|
+
*
|
|
4
|
+
* The SDK logs slider / device-lock / identity verification links (they carry a session id: `sid`,
|
|
5
|
+
* `sig`, `uin-token`) and the account's phone number at `mark` level, which `log_level: 'info'`
|
|
6
|
+
* does not suppress. The wrapper keeps every SDK diagnostic but strips link queries and phone
|
|
7
|
+
* numbers before they reach the SDK's log4js logger.
|
|
8
|
+
*
|
|
9
|
+
* Not covered (SDK code that bypasses its logger): the terminal QR code drawing and the
|
|
10
|
+
* `console.log(proto.toJSON())` of the legacy 237 branch; see the README "登录" section.
|
|
11
|
+
*/
|
|
12
|
+
const LEVELS = ['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'mark'] as const;
|
|
13
|
+
type Level = typeof LEVELS[number];
|
|
14
|
+
const REDACTED = Symbol('icqq.redacted-logger');
|
|
15
|
+
|
|
16
|
+
export interface IcqqSdkLogger {
|
|
17
|
+
level?: unknown;
|
|
18
|
+
trace(...args: unknown[]): unknown;
|
|
19
|
+
debug(...args: unknown[]): unknown;
|
|
20
|
+
info(...args: unknown[]): unknown;
|
|
21
|
+
warn(...args: unknown[]): unknown;
|
|
22
|
+
error(...args: unknown[]): unknown;
|
|
23
|
+
fatal(...args: unknown[]): unknown;
|
|
24
|
+
mark(...args: unknown[]): unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Links keep scheme, host and path; query and fragment become `…`. Phone numbers become `***`. */
|
|
28
|
+
export function redactIcqqLogText(text: string): string {
|
|
29
|
+
return text
|
|
30
|
+
.replace(/(https?:\/\/[^\s?#"'<>]+)[?#][^\s"'<>]*/giu, '$1?…')
|
|
31
|
+
// Masked (138****0000) and full mainland numbers, with or without a country code.
|
|
32
|
+
.replace(/(?<![\w*])(?:\+?86[- ]?)?1\d{2}(?:\*{2,}|\d{4})\d{4}(?![\w*])/gu, '***')
|
|
33
|
+
.replace(/(?<![\w*])\d{2,3}\*{2,}\d{2,4}(?![\w*])/gu, '***');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function redactArg(value: unknown): unknown {
|
|
37
|
+
if (typeof value === 'string') return redactIcqqLogText(value);
|
|
38
|
+
if (value instanceof Error) return redactIcqqLogText(value.stack ?? value.message);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Wrap the SDK logger; `level` reads and writes through to the original. */
|
|
43
|
+
export function redactingIcqqLogger<T extends IcqqSdkLogger>(original: T): T {
|
|
44
|
+
if ((original as { [REDACTED]?: true })[REDACTED]) return original;
|
|
45
|
+
const wrapper = Object.create(null) as Record<Level, (...args: unknown[]) => unknown> & { level?: unknown; [REDACTED]?: true };
|
|
46
|
+
for (const level of LEVELS) {
|
|
47
|
+
wrapper[level] = (...args: unknown[]) => original[level](...args.map(redactArg));
|
|
48
|
+
}
|
|
49
|
+
Object.defineProperty(wrapper, 'level', {
|
|
50
|
+
get: () => original.level,
|
|
51
|
+
set: (value: unknown) => { original.level = value; },
|
|
52
|
+
enumerable: true,
|
|
53
|
+
});
|
|
54
|
+
Object.defineProperty(wrapper, REDACTED, { value: true });
|
|
55
|
+
return wrapper as unknown as T;
|
|
56
|
+
}
|
|
57
|
+
|