Plus中添加GPC模式
This commit is contained in:
@@ -33,6 +33,7 @@
|
||||
executeStepViaCompletionSignal,
|
||||
exportSettingsBundle,
|
||||
fetchGeneratedEmail,
|
||||
refreshGpcCardBalance,
|
||||
finalizePhoneActivationAfterSuccessfulFlow,
|
||||
finalizeStep3Completion,
|
||||
finalizeIcloudAliasAfterSuccessfulFlow,
|
||||
@@ -203,6 +204,22 @@
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function normalizePlusPaymentMethodForDisplay(value = '') {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'gpc-helper') {
|
||||
return 'gpc-helper';
|
||||
}
|
||||
return normalized === 'gopay' ? 'gopay' : 'paypal';
|
||||
}
|
||||
|
||||
function getPlusPaymentMethodLabel(value = '') {
|
||||
const method = normalizePlusPaymentMethodForDisplay(value);
|
||||
if (method === 'gpc-helper') {
|
||||
return 'GPC';
|
||||
}
|
||||
return method === 'gopay' ? 'GoPay' : 'PayPal';
|
||||
}
|
||||
|
||||
async function handlePlatformVerifyStepData(payload) {
|
||||
if (payload.localhostUrl) {
|
||||
await closeLocalhostCallbackTabs(payload.localhostUrl);
|
||||
@@ -550,6 +567,8 @@
|
||||
const confirmed = Boolean(message.payload?.confirmed);
|
||||
const requestId = String(message.payload?.requestId || '').trim();
|
||||
const currentRequestId = String(currentState?.plusManualConfirmationRequestId || '').trim();
|
||||
const method = String(currentState?.plusManualConfirmationMethod || '').trim().toLowerCase();
|
||||
const isGpcOtp = method === 'gopay-otp';
|
||||
if (!currentState?.plusManualConfirmationPending) {
|
||||
return { ok: true, ignored: true };
|
||||
}
|
||||
@@ -565,15 +584,31 @@
|
||||
plusManualConfirmationTitle: '',
|
||||
plusManualConfirmationMessage: '',
|
||||
};
|
||||
|
||||
if (isGpcOtp && confirmed) {
|
||||
const otp = String(message.payload?.otp || message.payload?.code || '').trim().replace(/[^\d]/g, '');
|
||||
if (!otp) {
|
||||
throw new Error('请输入 GPC OTP 验证码。');
|
||||
}
|
||||
const otpUpdates = {
|
||||
...clearManualConfirmationState,
|
||||
gopayHelperResolvedOtp: otp,
|
||||
};
|
||||
await setState(otpUpdates);
|
||||
if (typeof broadcastDataUpdate === 'function') {
|
||||
broadcastDataUpdate(otpUpdates);
|
||||
}
|
||||
await addLog(`步骤 ${step}:已收到 GPC OTP,准备提交验证。`, 'ok');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
await setState(clearManualConfirmationState);
|
||||
if (typeof broadcastDataUpdate === 'function') {
|
||||
broadcastDataUpdate(clearManualConfirmationState);
|
||||
}
|
||||
|
||||
if (confirmed) {
|
||||
const methodLabel = String(currentState?.plusManualConfirmationMethod || '').trim().toLowerCase() === 'gopay'
|
||||
? 'GoPay'
|
||||
: '手动';
|
||||
const methodLabel = method === 'gopay' ? 'GoPay' : '手动';
|
||||
await addLog(`步骤 ${step}:已确认${methodLabel}订阅完成,准备继续下一步。`, 'ok');
|
||||
await completeStepFromBackground(step, {
|
||||
plusManualConfirmationMethod: currentState?.plusManualConfirmationMethod || '',
|
||||
@@ -582,9 +617,9 @@
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const cancelMessage = String(currentState?.plusManualConfirmationMethod || '').trim().toLowerCase() === 'gopay'
|
||||
const cancelMessage = method === 'gopay'
|
||||
? '已取消 GoPay 订阅确认'
|
||||
: '已取消当前手动确认';
|
||||
: (isGpcOtp ? '已取消 GPC OTP 输入' : '已取消当前手动确认');
|
||||
await setStepStatus(step, 'failed');
|
||||
await addLog(`步骤 ${step}:${cancelMessage}。`, 'warn');
|
||||
await appendManualAccountRunRecordIfNeeded(`step${step}_failed`, null, cancelMessage);
|
||||
@@ -836,8 +871,8 @@
|
||||
const modeChanged = Object.prototype.hasOwnProperty.call(updates, 'plusModeEnabled')
|
||||
&& Boolean(currentState?.plusModeEnabled) !== Boolean(updates.plusModeEnabled);
|
||||
const plusPaymentChanged = Object.prototype.hasOwnProperty.call(updates, 'plusPaymentMethod')
|
||||
&& String(currentState?.plusPaymentMethod || 'paypal').trim().toLowerCase()
|
||||
!== String(updates.plusPaymentMethod || 'paypal').trim().toLowerCase();
|
||||
&& normalizePlusPaymentMethodForDisplay(currentState?.plusPaymentMethod || 'paypal')
|
||||
!== normalizePlusPaymentMethodForDisplay(updates.plusPaymentMethod || 'paypal');
|
||||
const nextPlusModeEnabled = Object.prototype.hasOwnProperty.call(updates, 'plusModeEnabled')
|
||||
? Boolean(updates.plusModeEnabled)
|
||||
: Boolean(currentState?.plusModeEnabled);
|
||||
@@ -912,11 +947,9 @@
|
||||
await setContributionMode(true);
|
||||
}
|
||||
if (modeChanged) {
|
||||
const selectedPlusPaymentMethod = String(
|
||||
(stateUpdates.plusPaymentMethod ?? currentState?.plusPaymentMethod ?? 'paypal')
|
||||
).trim().toLowerCase() === 'gopay'
|
||||
? 'GoPay'
|
||||
: 'PayPal';
|
||||
const selectedPlusPaymentMethod = getPlusPaymentMethodLabel(
|
||||
stateUpdates.plusPaymentMethod ?? currentState?.plusPaymentMethod ?? 'paypal'
|
||||
);
|
||||
await addLog(
|
||||
Boolean(updates.plusModeEnabled)
|
||||
? `Plus 模式已开启,已切换为 Plus Checkout 步骤,当前支付方式:${selectedPlusPaymentMethod}。`
|
||||
@@ -924,16 +957,28 @@
|
||||
'info'
|
||||
);
|
||||
} else if (plusPaymentChanged && nextPlusModeEnabled) {
|
||||
const selectedPlusPaymentMethod = String(
|
||||
const selectedPlusPaymentMethod = getPlusPaymentMethodLabel(
|
||||
stateUpdates.plusPaymentMethod ?? currentState?.plusPaymentMethod ?? 'paypal'
|
||||
).trim().toLowerCase() === 'gopay'
|
||||
? 'GoPay'
|
||||
: 'PayPal';
|
||||
);
|
||||
await addLog(`Plus 支付方式已切换为 ${selectedPlusPaymentMethod},已更新对应的 Plus 步骤。`, 'info');
|
||||
}
|
||||
return { ok: true, state: await getState(), proxyRouting };
|
||||
}
|
||||
|
||||
case 'REFRESH_GPC_CARD_BALANCE': {
|
||||
if (typeof refreshGpcCardBalance !== 'function') {
|
||||
throw new Error('GPC 卡密余额查询能力尚未接入。');
|
||||
}
|
||||
const state = await getState();
|
||||
const result = await refreshGpcCardBalance({
|
||||
...(state || {}),
|
||||
...(message.payload || {}),
|
||||
}, {
|
||||
reason: message.payload?.reason,
|
||||
});
|
||||
return { ok: true, ...result };
|
||||
}
|
||||
|
||||
case 'RUN_IP_PROXY_AUTO_SYNC_NOW': {
|
||||
if (typeof runIpProxyAutoSync !== 'function') {
|
||||
throw new Error('IP 代理自动同步能力尚未接入。');
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
const PLUS_CHECKOUT_SOURCE = 'plus-checkout';
|
||||
const PLUS_CHECKOUT_ENTRY_URL = 'https://chatgpt.com/';
|
||||
const PLUS_CHECKOUT_INJECT_FILES = ['content/utils.js', 'content/plus-checkout.js'];
|
||||
const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';
|
||||
const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';
|
||||
const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper';
|
||||
|
||||
function createPlusCheckoutCreateExecutor(deps = {}) {
|
||||
const {
|
||||
@@ -12,21 +14,42 @@
|
||||
chrome,
|
||||
completeStepFromBackground,
|
||||
ensureContentScriptReadyOnTabUntilStopped,
|
||||
fetch: fetchImpl = null,
|
||||
markCurrentRegistrationAccountUsed = null,
|
||||
registerTab,
|
||||
sendTabMessageUntilStopped,
|
||||
setState,
|
||||
sleepWithStop,
|
||||
waitForTabCompleteUntilStopped,
|
||||
throwIfStopped = () => {},
|
||||
} = deps;
|
||||
|
||||
function normalizePlusPaymentMethod(value = '') {
|
||||
return String(value || '').trim().toLowerCase() === 'gopay' ? 'gopay' : 'paypal';
|
||||
const rootScope = typeof self !== 'undefined' ? self : globalThis;
|
||||
if (rootScope.GoPayUtils?.normalizePlusPaymentMethod) {
|
||||
return rootScope.GoPayUtils.normalizePlusPaymentMethod(value);
|
||||
}
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === PLUS_PAYMENT_METHOD_GPC_HELPER) {
|
||||
return PLUS_PAYMENT_METHOD_GPC_HELPER;
|
||||
}
|
||||
return normalized === PLUS_PAYMENT_METHOD_GOPAY ? PLUS_PAYMENT_METHOD_GOPAY : PLUS_PAYMENT_METHOD_PAYPAL;
|
||||
}
|
||||
|
||||
function getCheckoutModeLabel(state = {}) {
|
||||
return normalizePlusPaymentMethod(state?.plusPaymentMethod) === 'gopay'
|
||||
? 'GoPay 订阅页'
|
||||
: 'Plus Checkout';
|
||||
const paymentMethod = normalizePlusPaymentMethod(state?.plusPaymentMethod);
|
||||
if (paymentMethod === PLUS_PAYMENT_METHOD_GPC_HELPER) {
|
||||
return 'GPC 订阅页';
|
||||
}
|
||||
return paymentMethod === PLUS_PAYMENT_METHOD_GOPAY ? 'GoPay 订阅页' : 'Plus Checkout';
|
||||
}
|
||||
|
||||
function getPlusPaymentMethodLabel(method = PLUS_PAYMENT_METHOD_PAYPAL) {
|
||||
const paymentMethod = normalizePlusPaymentMethod(method);
|
||||
if (paymentMethod === PLUS_PAYMENT_METHOD_GPC_HELPER) {
|
||||
return 'GPC';
|
||||
}
|
||||
return paymentMethod === PLUS_PAYMENT_METHOD_GOPAY ? 'GoPay' : 'PayPal';
|
||||
}
|
||||
|
||||
async function openFreshChatGptTabForCheckoutCreate() {
|
||||
@@ -41,19 +64,345 @@
|
||||
return tabId;
|
||||
}
|
||||
|
||||
function normalizePlusPaymentMethod(value = '') {
|
||||
return String(value || '').trim().toLowerCase() === PLUS_PAYMENT_METHOD_GOPAY ? 'gopay' : 'paypal';
|
||||
function normalizeHelperCountryCode(countryCode = '86') {
|
||||
const digits = String(countryCode || '').replace(/\D/g, '');
|
||||
return digits || '86';
|
||||
}
|
||||
|
||||
function getPlusPaymentMethodLabel(method = 'paypal') {
|
||||
return normalizePlusPaymentMethod(method) === PLUS_PAYMENT_METHOD_GOPAY ? 'GoPay' : 'PayPal';
|
||||
function normalizeHelperPhoneNumber(phone = '', countryCode = '86') {
|
||||
const cleaned = String(phone || '').replace(/\D/g, '');
|
||||
const countryDigits = normalizeHelperCountryCode(countryCode);
|
||||
if (countryDigits && cleaned.startsWith(countryDigits) && cleaned.length > countryDigits.length) {
|
||||
return cleaned.slice(countryDigits.length);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function resolveGpcHelperCardKey(state = {}) {
|
||||
const cardKey = String(state?.gopayHelperCardKey || state?.gpcCardKey || state?.cardKey || '').trim();
|
||||
if (!cardKey) {
|
||||
throw new Error('创建 GPC 订单失败:缺少卡密。');
|
||||
}
|
||||
return cardKey;
|
||||
}
|
||||
|
||||
function resolveGpcHelperCustomerEmail(state = {}) {
|
||||
const email = String(
|
||||
state?.email
|
||||
|| state?.currentEmail
|
||||
|| state?.registrationEmail
|
||||
|| state?.accountEmail
|
||||
|| state?.mailboxEmail
|
||||
|| ''
|
||||
).trim().toLowerCase();
|
||||
if (!email) {
|
||||
throw new Error('创建 GPC 订单失败:缺少当前轮邮箱。');
|
||||
}
|
||||
return email;
|
||||
}
|
||||
|
||||
function parseGpcAmount(value) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? { amount: value, raw: String(value) } : null;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw || !/\d/.test(raw)) {
|
||||
return null;
|
||||
}
|
||||
const match = raw.match(/([+-]?\d{1,3}(?:[.,]\d{3})*(?:[.,]\d{1,2})|[+-]?\d+(?:[.,]\d{1,2})?)/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
let numericText = String(match[1] || '').trim();
|
||||
const lastComma = numericText.lastIndexOf(',');
|
||||
const lastDot = numericText.lastIndexOf('.');
|
||||
if (lastComma > -1 && lastDot > -1) {
|
||||
const decimalSeparator = lastComma > lastDot ? ',' : '.';
|
||||
const thousandsSeparator = decimalSeparator === ',' ? '.' : ',';
|
||||
numericText = numericText
|
||||
.replace(new RegExp(`\\${thousandsSeparator}`, 'g'), '')
|
||||
.replace(decimalSeparator, '.');
|
||||
} else if (lastComma > -1) {
|
||||
numericText = numericText.replace(',', '.');
|
||||
}
|
||||
const amount = Number(numericText.replace(/[^\d.+-]/g, ''));
|
||||
return Number.isFinite(amount) ? { amount, raw } : null;
|
||||
}
|
||||
|
||||
function isGpcAmountKey(key = '') {
|
||||
const normalized = String(key || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '_');
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (/(?:^|_)(?:id|guid|uuid|phone|country|postal|zip|code|count|status|time|timestamp|created|updated|expires|challenge|client|reference|currency|state)(?:_|$)/i.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return /(?:amount|balance|total|due|payable|gross|subtotal|price|charge)/i.test(normalized);
|
||||
}
|
||||
|
||||
function findGpcNonZeroAmount(payload = {}) {
|
||||
const seen = new Set();
|
||||
function visit(value, path = [], depth = 0) {
|
||||
if (value == null || depth > 10) {
|
||||
return null;
|
||||
}
|
||||
const key = path[path.length - 1] || '';
|
||||
if (isGpcAmountKey(key)) {
|
||||
const parsed = parseGpcAmount(value);
|
||||
if (parsed && Math.abs(parsed.amount) >= 0.005) {
|
||||
return { ...parsed, path: path.join('.') };
|
||||
}
|
||||
}
|
||||
if (typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
if (seen.has(value)) {
|
||||
return null;
|
||||
}
|
||||
seen.add(value);
|
||||
if (Array.isArray(value)) {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const found = visit(value[index], [...path, String(index)], depth + 1);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
for (const [childKey, childValue] of Object.entries(value)) {
|
||||
const found = visit(childValue, [...path, childKey], depth + 1);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return visit(payload);
|
||||
}
|
||||
|
||||
async function abortGpcNonFreeTrialIfNeeded(data = {}, state = {}) {
|
||||
const nonZeroAmount = findGpcNonZeroAmount(data);
|
||||
if (!nonZeroAmount) {
|
||||
return;
|
||||
}
|
||||
const amountLabel = nonZeroAmount.raw || String(nonZeroAmount.amount);
|
||||
await addLog(`步骤 6:GPC 接口返回余额非 0(${amountLabel}),当前账号没有免费试用资格,将跳过当前账号。`, 'warn');
|
||||
if (typeof markCurrentRegistrationAccountUsed === 'function') {
|
||||
await markCurrentRegistrationAccountUsed(state, {
|
||||
reason: 'plus-checkout-non-free-trial',
|
||||
logPrefix: 'GPC:当前账号没有免费试用资格',
|
||||
});
|
||||
}
|
||||
throw new Error(`PLUS_CHECKOUT_NON_FREE_TRIAL::步骤 6:GPC 接口返回余额非 0(${amountLabel}),当前账号没有免费试用资格,已跳过支付提交。`);
|
||||
}
|
||||
|
||||
function normalizeGpcHelperBaseUrl(apiUrl = '') {
|
||||
const rootScope = typeof self !== 'undefined' ? self : globalThis;
|
||||
if (rootScope.GoPayUtils?.normalizeGpcHelperBaseUrl) {
|
||||
return rootScope.GoPayUtils.normalizeGpcHelperBaseUrl(apiUrl);
|
||||
}
|
||||
let normalized = String(apiUrl || '').trim().replace(/\/+$/g, '');
|
||||
normalized = normalized.replace(/\/api\/checkout\/start$/i, '');
|
||||
normalized = normalized.replace(/\/api\/gopay\/(?:otp|pin)$/i, '');
|
||||
normalized = normalized.replace(/\/api\/card\/balance(?:\?.*)?$/i, '');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function buildGpcHelperApiUrl(apiUrl = '', path = '') {
|
||||
const rootScope = typeof self !== 'undefined' ? self : globalThis;
|
||||
if (rootScope.GoPayUtils?.buildGpcHelperApiUrl) {
|
||||
return rootScope.GoPayUtils.buildGpcHelperApiUrl(apiUrl, path);
|
||||
}
|
||||
const baseUrl = normalizeGpcHelperBaseUrl(apiUrl);
|
||||
if (!baseUrl) {
|
||||
return '';
|
||||
}
|
||||
const normalizedPath = String(path || '').startsWith('/') ? String(path || '') : `/${String(path || '')}`;
|
||||
return `${baseUrl}${normalizedPath}`;
|
||||
}
|
||||
|
||||
async function fetchJsonWithTimeout(url, options = {}, timeoutMs = 30000) {
|
||||
const fetcher = typeof fetchImpl === 'function'
|
||||
? fetchImpl
|
||||
: (typeof fetch === 'function' ? fetch.bind(globalThis) : null);
|
||||
if (typeof fetcher !== 'function') {
|
||||
throw new Error('当前运行环境不支持 fetch,无法调用 GPC API。');
|
||||
}
|
||||
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
||||
const timer = controller ? setTimeout(() => controller.abort(), Math.max(1000, Number(timeoutMs) || 30000)) : null;
|
||||
try {
|
||||
const response = await fetcher(url, { ...options, ...(controller ? { signal: controller.signal } : {}) });
|
||||
const data = await response.json().catch(() => ({}));
|
||||
return { response, data };
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function readAccessTokenFromChatGptSessionTab(tabId) {
|
||||
await waitForTabCompleteUntilStopped(tabId);
|
||||
await sleepWithStop(1000);
|
||||
await ensureContentScriptReadyOnTabUntilStopped(PLUS_CHECKOUT_SOURCE, tabId, {
|
||||
inject: PLUS_CHECKOUT_INJECT_FILES,
|
||||
injectSource: PLUS_CHECKOUT_SOURCE,
|
||||
logMessage: '步骤 6:正在等待 ChatGPT 页面完成加载,再继续获取 accessToken...',
|
||||
});
|
||||
|
||||
const sessionResult = await sendTabMessageUntilStopped(tabId, PLUS_CHECKOUT_SOURCE, {
|
||||
type: 'PLUS_CHECKOUT_GET_STATE',
|
||||
source: 'background',
|
||||
payload: {
|
||||
includeSession: true,
|
||||
includeAccessToken: true,
|
||||
},
|
||||
});
|
||||
if (sessionResult?.error) {
|
||||
throw new Error(sessionResult.error);
|
||||
}
|
||||
return String(sessionResult?.accessToken || sessionResult?.session?.accessToken || '').trim();
|
||||
}
|
||||
|
||||
async function generateGpcCheckoutFromApi(accessToken = '', state = {}) {
|
||||
const token = String(accessToken || '').trim();
|
||||
if (!token) {
|
||||
throw new Error('创建 GPC 订单失败:缺少 accessToken。');
|
||||
}
|
||||
const apiUrl = buildGpcHelperApiUrl(state?.gopayHelperApiUrl, '/api/checkout/start');
|
||||
if (!apiUrl) {
|
||||
throw new Error('创建 GPC 订单失败:缺少 API 地址。');
|
||||
}
|
||||
const phoneNumber = String(state?.gopayHelperPhoneNumber || '').trim();
|
||||
const countryCode = normalizeHelperCountryCode(state?.gopayHelperCountryCode || '86');
|
||||
const pin = String(state?.gopayHelperPin || '').trim();
|
||||
const cardKey = resolveGpcHelperCardKey(state);
|
||||
if (!phoneNumber) {
|
||||
throw new Error('创建 GPC 订单失败:缺少手机号。');
|
||||
}
|
||||
if (!pin) {
|
||||
throw new Error('创建 GPC 订单失败:缺少 PIN。');
|
||||
}
|
||||
|
||||
throwIfStopped();
|
||||
const payload = {
|
||||
token,
|
||||
entry_point: 'all_plans_pricing_modal',
|
||||
plan_name: 'chatgptplusplan',
|
||||
billing_details: { country: 'ID', currency: 'IDR' },
|
||||
promo_campaign: {
|
||||
promo_campaign_id: 'plus-1-month-free',
|
||||
is_coupon_from_query_param: false,
|
||||
},
|
||||
checkout_ui_mode: 'custom',
|
||||
proxy: { type: 'direct', url: '' },
|
||||
tax_region: {
|
||||
country: 'US',
|
||||
line1: '1208 Oakdale Street',
|
||||
city: 'Jonesboro',
|
||||
postal_code: '72401',
|
||||
state: 'AR',
|
||||
},
|
||||
customer_email: resolveGpcHelperCustomerEmail(state),
|
||||
card_key: cardKey,
|
||||
gopay_link: {
|
||||
type: 'gopay',
|
||||
country_code: countryCode,
|
||||
phone_number: normalizeHelperPhoneNumber(phoneNumber, countryCode),
|
||||
},
|
||||
};
|
||||
|
||||
const { response, data } = await fetchJsonWithTimeout(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: '*/*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}, 30000);
|
||||
|
||||
const referenceId = String(data?.reference_id || data?.referenceId || '').trim();
|
||||
const gopayGuid = String(data?.gopay_guid || data?.gopayGuid || '').trim();
|
||||
const redirectUrl = String(data?.redirect_url || data?.redirectUrl || '').trim();
|
||||
const nextAction = String(data?.next_action || data?.nextAction || '').trim();
|
||||
const flowId = String(data?.flow_id || data?.flowId || '').trim();
|
||||
const challengeId = String(data?.challenge_id || data?.challengeId || '').trim();
|
||||
|
||||
if (response?.ok) {
|
||||
await abortGpcNonFreeTrialIfNeeded(data, state);
|
||||
}
|
||||
|
||||
if (!response?.ok || !referenceId) {
|
||||
const rootScope = typeof self !== 'undefined' ? self : globalThis;
|
||||
const detail = rootScope.GoPayUtils?.extractGpcResponseErrorDetail
|
||||
? rootScope.GoPayUtils.extractGpcResponseErrorDetail(data, response?.status || 0)
|
||||
: (data?.detail || data?.message || data?.error || `HTTP ${response?.status || 0}`);
|
||||
throw new Error(`创建 GPC 订单失败:${detail}`);
|
||||
}
|
||||
|
||||
return {
|
||||
referenceId,
|
||||
gopayGuid,
|
||||
redirectUrl,
|
||||
nextAction,
|
||||
flowId,
|
||||
challengeId,
|
||||
responsePayload: data && typeof data === 'object' && !Array.isArray(data) ? data : null,
|
||||
country: 'ID',
|
||||
currency: 'IDR',
|
||||
checkoutSource: PLUS_PAYMENT_METHOD_GPC_HELPER,
|
||||
};
|
||||
}
|
||||
|
||||
async function executeGpcCheckoutCreate(state = {}) {
|
||||
let accessToken = String(state?.contributionAccessToken || state?.accessToken || state?.chatgptAccessToken || '').trim();
|
||||
if (!accessToken) {
|
||||
await addLog('步骤 6:正在获取 accessToken...', 'info');
|
||||
const tokenTabId = await openFreshChatGptTabForCheckoutCreate();
|
||||
try {
|
||||
accessToken = await readAccessTokenFromChatGptSessionTab(tokenTabId);
|
||||
} finally {
|
||||
if (chrome?.tabs?.remove && Number.isInteger(tokenTabId)) {
|
||||
await chrome.tabs.remove(tokenTabId).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!accessToken) {
|
||||
throw new Error('步骤 6:GPC 模式获取 accessToken 失败。');
|
||||
}
|
||||
|
||||
await addLog('步骤 6:正在调用 GPC 接口创建订单...', 'info');
|
||||
const result = await generateGpcCheckoutFromApi(accessToken, state);
|
||||
await setState({
|
||||
plusCheckoutTabId: null,
|
||||
plusCheckoutUrl: '',
|
||||
plusCheckoutCountry: result.country || 'ID',
|
||||
plusCheckoutCurrency: result.currency || 'IDR',
|
||||
plusCheckoutSource: result.checkoutSource,
|
||||
gopayHelperReferenceId: result.referenceId,
|
||||
gopayHelperGoPayGuid: result.gopayGuid,
|
||||
gopayHelperRedirectUrl: result.redirectUrl,
|
||||
gopayHelperNextAction: result.nextAction,
|
||||
gopayHelperFlowId: result.flowId,
|
||||
gopayHelperChallengeId: result.challengeId,
|
||||
gopayHelperStartPayload: result.responsePayload,
|
||||
});
|
||||
await addLog('步骤 6:GPC 订单已创建,准备继续下一步。', 'info');
|
||||
await completeStepFromBackground(6, {
|
||||
plusCheckoutCountry: result.country || 'ID',
|
||||
plusCheckoutCurrency: result.currency || 'IDR',
|
||||
plusCheckoutSource: result.checkoutSource,
|
||||
});
|
||||
}
|
||||
|
||||
async function executePlusCheckoutCreate(state = {}) {
|
||||
const paymentMethod = normalizePlusPaymentMethod(state?.plusPaymentMethod);
|
||||
if (paymentMethod === PLUS_PAYMENT_METHOD_GPC_HELPER) {
|
||||
await executeGpcCheckoutCreate(state);
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentMethodLabel = getPlusPaymentMethodLabel(paymentMethod);
|
||||
const checkoutModeLabel = getCheckoutModeLabel(state);
|
||||
await addLog('步骤 6:正在新打开 ChatGPT 会话页,准备创建 Plus Checkout...', 'info');
|
||||
await addLog(`步骤 6:正在打开新的 ChatGPT 会话,准备创建${checkoutModeLabel}...`, 'info');
|
||||
const tabId = await openFreshChatGptTabForCheckoutCreate();
|
||||
|
||||
await waitForTabCompleteUntilStopped(tabId);
|
||||
@@ -92,6 +441,7 @@
|
||||
plusCheckoutUrl: result.checkoutUrl,
|
||||
plusCheckoutCountry: result.country || 'DE',
|
||||
plusCheckoutCurrency: result.currency || 'EUR',
|
||||
plusCheckoutSource: '',
|
||||
});
|
||||
|
||||
await addLog(`步骤 6:Plus Checkout 页面已就绪(${paymentMethodLabel} / ${result.country || 'DE'} ${result.currency || 'EUR'}),准备继续下一步。`, 'info');
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
const PLUS_CHECKOUT_PAYPAL_REDIRECT_TIMEOUT_MS = 20000;
|
||||
const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';
|
||||
const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';
|
||||
const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper';
|
||||
const PAYMENT_METHOD_CONFIGS = {
|
||||
[PLUS_PAYMENT_METHOD_PAYPAL]: {
|
||||
id: PLUS_PAYMENT_METHOD_PAYPAL,
|
||||
@@ -53,12 +54,14 @@
|
||||
function createPlusCheckoutBillingExecutor(deps = {}) {
|
||||
const {
|
||||
addLog,
|
||||
broadcastDataUpdate,
|
||||
chrome,
|
||||
completeStepFromBackground,
|
||||
ensureContentScriptReadyOnTabUntilStopped,
|
||||
fetch: fetchImpl = null,
|
||||
generateRandomName,
|
||||
getAddressSeedForCountry,
|
||||
getState,
|
||||
getTabId,
|
||||
isTabAlive,
|
||||
markCurrentRegistrationAccountUsed,
|
||||
@@ -66,6 +69,7 @@
|
||||
sleepWithStop,
|
||||
waitForTabCompleteUntilStopped,
|
||||
probeIpProxyExit = null,
|
||||
throwIfStopped = () => {},
|
||||
} = deps;
|
||||
|
||||
function isPlusCheckoutUrl(url = '') {
|
||||
@@ -76,20 +80,307 @@
|
||||
return String(value || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function isGpcHelperCheckout(state = {}) {
|
||||
return normalizePlusPaymentMethod(state?.plusPaymentMethod) === PLUS_PAYMENT_METHOD_GPC_HELPER
|
||||
|| (normalizeText(state?.plusCheckoutSource) === PLUS_PAYMENT_METHOD_GPC_HELPER
|
||||
&& Boolean(state?.gopayHelperReferenceId));
|
||||
}
|
||||
|
||||
function compactCountryText(value = '') {
|
||||
return normalizeText(value).toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]/g, '');
|
||||
}
|
||||
|
||||
function normalizePlusPaymentMethod(value = '') {
|
||||
return String(value || '').trim().toLowerCase() === PLUS_PAYMENT_METHOD_GOPAY
|
||||
? PLUS_PAYMENT_METHOD_GOPAY
|
||||
: PLUS_PAYMENT_METHOD_PAYPAL;
|
||||
const rootScope = typeof self !== 'undefined' ? self : globalThis;
|
||||
if (rootScope.GoPayUtils?.normalizePlusPaymentMethod) {
|
||||
return rootScope.GoPayUtils.normalizePlusPaymentMethod(value);
|
||||
}
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === PLUS_PAYMENT_METHOD_GPC_HELPER) {
|
||||
return PLUS_PAYMENT_METHOD_GPC_HELPER;
|
||||
}
|
||||
return normalized === PLUS_PAYMENT_METHOD_GOPAY ? PLUS_PAYMENT_METHOD_GOPAY : PLUS_PAYMENT_METHOD_PAYPAL;
|
||||
}
|
||||
|
||||
function getPaymentMethodConfig(method = PLUS_PAYMENT_METHOD_PAYPAL) {
|
||||
return PAYMENT_METHOD_CONFIGS[normalizePlusPaymentMethod(method)] || PAYMENT_METHOD_CONFIGS[PLUS_PAYMENT_METHOD_PAYPAL];
|
||||
}
|
||||
|
||||
function normalizeGpcHelperBaseUrl(apiUrl = '') {
|
||||
const rootScope = typeof self !== 'undefined' ? self : globalThis;
|
||||
if (rootScope.GoPayUtils?.normalizeGpcHelperBaseUrl) {
|
||||
return rootScope.GoPayUtils.normalizeGpcHelperBaseUrl(apiUrl);
|
||||
}
|
||||
let normalized = String(apiUrl || '').trim().replace(/\/+$/g, '');
|
||||
normalized = normalized.replace(/\/api\/checkout\/start$/i, '');
|
||||
normalized = normalized.replace(/\/api\/gopay\/(?:otp|pin)$/i, '');
|
||||
normalized = normalized.replace(/\/api\/card\/balance(?:\?.*)?$/i, '');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function fetchJsonWithTimeout(url, options = {}, timeoutMs = 30000) {
|
||||
const fetcher = typeof fetchImpl === 'function'
|
||||
? fetchImpl
|
||||
: (typeof fetch === 'function' ? fetch.bind(globalThis) : null);
|
||||
if (typeof fetcher !== 'function') {
|
||||
throw new Error('当前运行环境不支持 fetch,无法调用 GPC API。');
|
||||
}
|
||||
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
||||
const timer = controller ? setTimeout(() => controller.abort(), Math.max(1000, Number(timeoutMs) || 30000)) : null;
|
||||
try {
|
||||
const response = await fetcher(url, { ...options, ...(controller ? { signal: controller.signal } : {}) });
|
||||
const data = await response.json().catch(() => ({}));
|
||||
return { response, data };
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function buildGpcOtpPayload(input = {}) {
|
||||
const rootScope = typeof self !== 'undefined' ? self : globalThis;
|
||||
if (rootScope.GoPayUtils?.buildGpcOtpPayload) {
|
||||
return rootScope.GoPayUtils.buildGpcOtpPayload(input);
|
||||
}
|
||||
const payload = {
|
||||
reference_id: String(input.reference_id ?? input.referenceId ?? '').trim(),
|
||||
otp: String(input.otp ?? input.code ?? '').trim().replace(/[^\d]/g, ''),
|
||||
card_key: String(input.card_key ?? input.cardKey ?? '').trim(),
|
||||
};
|
||||
const gopayGuid = String(input.gopay_guid ?? input.gopayGuid ?? '').trim();
|
||||
const redirectUrl = String(input.redirect_url ?? input.redirectUrl ?? '').trim();
|
||||
const flowId = String(input.flow_id ?? input.flowId ?? '').trim();
|
||||
if (flowId) payload.flow_id = flowId;
|
||||
if (gopayGuid) payload.gopay_guid = gopayGuid;
|
||||
if (redirectUrl) payload.redirect_url = redirectUrl;
|
||||
return payload;
|
||||
}
|
||||
|
||||
function buildGpcOtpRetryPayload(input = {}) {
|
||||
const rootScope = typeof self !== 'undefined' ? self : globalThis;
|
||||
if (rootScope.GoPayUtils?.buildGpcOtpRetryPayload) {
|
||||
return rootScope.GoPayUtils.buildGpcOtpRetryPayload(input);
|
||||
}
|
||||
const basePayload = buildGpcOtpPayload(input);
|
||||
return { ...basePayload, code: basePayload.otp };
|
||||
}
|
||||
|
||||
function buildGpcPinPayload(input = {}) {
|
||||
const rootScope = typeof self !== 'undefined' ? self : globalThis;
|
||||
if (rootScope.GoPayUtils?.buildGpcPinPayload) {
|
||||
return rootScope.GoPayUtils.buildGpcPinPayload(input);
|
||||
}
|
||||
const payload = {
|
||||
reference_id: String(input.reference_id ?? input.referenceId ?? '').trim(),
|
||||
challenge_id: String(input.challenge_id ?? input.challengeId ?? '').trim(),
|
||||
gopay_guid: String(input.gopay_guid ?? input.gopayGuid ?? '').trim(),
|
||||
pin: String(input.pin ?? '').trim().replace(/[^\d]/g, ''),
|
||||
card_key: String(input.card_key ?? input.cardKey ?? '').trim(),
|
||||
};
|
||||
const redirectUrl = String(input.redirect_url ?? input.redirectUrl ?? '').trim();
|
||||
const flowId = String(input.flow_id ?? input.flowId ?? '').trim();
|
||||
if (flowId) payload.flow_id = flowId;
|
||||
if (redirectUrl) payload.redirect_url = redirectUrl;
|
||||
return payload;
|
||||
}
|
||||
|
||||
function buildGpcPinRetryPayload(input = {}) {
|
||||
const rootScope = typeof self !== 'undefined' ? self : globalThis;
|
||||
if (rootScope.GoPayUtils?.buildGpcPinRetryPayload) {
|
||||
return rootScope.GoPayUtils.buildGpcPinRetryPayload(input);
|
||||
}
|
||||
const basePayload = buildGpcPinPayload(input);
|
||||
return { ...basePayload, challengeId: basePayload.challenge_id };
|
||||
}
|
||||
|
||||
function getGpcResponseErrorDetail(payload = {}, status = 0) {
|
||||
const rootScope = typeof self !== 'undefined' ? self : globalThis;
|
||||
if (rootScope.GoPayUtils?.extractGpcResponseErrorDetail) {
|
||||
return rootScope.GoPayUtils.extractGpcResponseErrorDetail(payload, status);
|
||||
}
|
||||
if (payload && typeof payload === 'object') {
|
||||
return payload.detail || payload.message || payload.error || payload.error_description || payload.reason || `HTTP ${status || 0}`;
|
||||
}
|
||||
return `HTTP ${status || 0}`;
|
||||
}
|
||||
|
||||
async function postGpcJsonWithFallback(apiUrl, endpointPath, primaryPayload, fallbackPayload, timeoutMs = 30000) {
|
||||
const requestUrl = `${apiUrl}${endpointPath}`;
|
||||
const send = (payload) => fetchJsonWithTimeout(requestUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}, timeoutMs);
|
||||
const firstResponse = await send(primaryPayload);
|
||||
if (firstResponse?.response?.ok || !fallbackPayload) {
|
||||
return { ...firstResponse, retried: false, payload: primaryPayload };
|
||||
}
|
||||
const status = Number(firstResponse?.response?.status || 0);
|
||||
if (status !== 400 && status !== 422) {
|
||||
return { ...firstResponse, retried: false, payload: primaryPayload };
|
||||
}
|
||||
const firstDetail = getGpcResponseErrorDetail(firstResponse?.data, status);
|
||||
await addLog(`步骤 7:GPC 接口返回 ${status}(${firstDetail}),使用兼容字段重试。`, 'warn');
|
||||
const secondResponse = await send(fallbackPayload);
|
||||
return {
|
||||
...secondResponse,
|
||||
retried: true,
|
||||
payload: fallbackPayload,
|
||||
firstError: firstDetail,
|
||||
firstStatus: status,
|
||||
};
|
||||
}
|
||||
|
||||
function getStateInternal() {
|
||||
if (typeof getState === 'function') {
|
||||
return getState();
|
||||
}
|
||||
return Promise.resolve({});
|
||||
}
|
||||
|
||||
async function requestGpcOtpInput({ title = '', message = '', referenceId = '' }) {
|
||||
const requestId = `otp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const payload = {
|
||||
plusManualConfirmationPending: true,
|
||||
plusManualConfirmationRequestId: requestId,
|
||||
plusManualConfirmationStep: 7,
|
||||
plusManualConfirmationMethod: 'gopay-otp',
|
||||
plusManualConfirmationTitle: title || 'GPC OTP 验证',
|
||||
plusManualConfirmationMessage: message || '请输入 OTP 验证码',
|
||||
gopayHelperOtpRequestId: requestId,
|
||||
gopayHelperOtpReferenceId: referenceId,
|
||||
gopayHelperResolvedOtp: '',
|
||||
};
|
||||
await setState(payload);
|
||||
if (typeof broadcastDataUpdate === 'function') {
|
||||
broadcastDataUpdate(payload);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const checkInterval = setInterval(async () => {
|
||||
try {
|
||||
throwIfStopped();
|
||||
const currentState = await getStateInternal();
|
||||
if (!currentState?.plusManualConfirmationPending || currentState?.plusManualConfirmationRequestId !== requestId) {
|
||||
clearInterval(checkInterval);
|
||||
const resolvedOtp = String(currentState?.gopayHelperResolvedOtp || '').trim().replace(/[^\d]/g, '');
|
||||
if (resolvedOtp) {
|
||||
resolve(resolvedOtp);
|
||||
} else {
|
||||
reject(new Error('OTP 输入已取消'));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
clearInterval(checkInterval);
|
||||
reject(error);
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
|
||||
async function executeGpcHelperBilling(state = {}) {
|
||||
const referenceId = String(state?.gopayHelperReferenceId || '').trim();
|
||||
const apiUrl = normalizeGpcHelperBaseUrl(state?.gopayHelperApiUrl || '');
|
||||
const cardKey = String(state?.gopayHelperCardKey || state?.gpcCardKey || state?.cardKey || '').trim();
|
||||
if (!referenceId) {
|
||||
throw new Error('步骤 7:GPC 模式缺少 reference_id,请先执行步骤 6。');
|
||||
}
|
||||
if (!apiUrl) {
|
||||
throw new Error('步骤 7:GPC 模式缺少 API 地址。');
|
||||
}
|
||||
if (!cardKey) {
|
||||
throw new Error('步骤 7:GPC 模式缺少卡密。');
|
||||
}
|
||||
await addLog(`步骤 7:GPC 模式开始 OTP 验证(reference_id: ${referenceId})...`, 'info');
|
||||
await addLog('步骤 7:等待用户输入 OTP...', 'info');
|
||||
const otp = await requestGpcOtpInput({
|
||||
title: 'GPC OTP 验证',
|
||||
message: `请输入收到的 OTP 验证码(reference_id: ${referenceId})`,
|
||||
referenceId,
|
||||
});
|
||||
|
||||
const flowId = state?.gopayHelperFlowId
|
||||
|| state?.gopayHelperStartPayload?.flow_id
|
||||
|| state?.gopayHelperStartPayload?.flowId
|
||||
|| state?.gopayHelperBalancePayload?.flow_id
|
||||
|| state?.gopayHelperBalancePayload?.flowId
|
||||
|| '';
|
||||
const baseInput = {
|
||||
reference_id: referenceId,
|
||||
otp,
|
||||
card_key: cardKey,
|
||||
gopay_guid: state?.gopayHelperGoPayGuid || '',
|
||||
redirect_url: state?.gopayHelperRedirectUrl || '',
|
||||
flow_id: flowId,
|
||||
};
|
||||
await addLog('步骤 7:正在提交 OTP...', 'info');
|
||||
const otpResponse = await postGpcJsonWithFallback(
|
||||
apiUrl,
|
||||
'/api/gopay/otp',
|
||||
buildGpcOtpPayload(baseInput),
|
||||
buildGpcOtpRetryPayload(baseInput),
|
||||
30000
|
||||
);
|
||||
if (!otpResponse?.response?.ok) {
|
||||
throw new Error(`步骤 7:OTP 验证失败:${getGpcResponseErrorDetail(otpResponse?.data, otpResponse?.response?.status || 0)}`);
|
||||
}
|
||||
|
||||
const otpData = otpResponse?.data || {};
|
||||
const challengeId = String(
|
||||
otpData?.challenge_id
|
||||
|| otpData?.challengeId
|
||||
|| state?.gopayHelperChallengeId
|
||||
|| state?.gopayHelperStartPayload?.challenge_id
|
||||
|| state?.gopayHelperStartPayload?.challengeId
|
||||
|| ''
|
||||
).trim();
|
||||
const nextFlowId = String(otpData?.flow_id || otpData?.flowId || baseInput.flow_id || '').trim();
|
||||
const gopayGuid = String(otpData?.gopay_guid || otpData?.gopayGuid || state?.gopayHelperGoPayGuid || '').trim();
|
||||
const redirectUrl = String(otpData?.redirect_url || otpData?.redirectUrl || state?.gopayHelperRedirectUrl || '').trim();
|
||||
if (!challengeId) {
|
||||
throw new Error('步骤 7:GPC OTP 验证后未返回 challenge_id。');
|
||||
}
|
||||
const pin = String(state?.gopayHelperPin || '').trim().replace(/[^\d]/g, '');
|
||||
if (!pin) {
|
||||
throw new Error('步骤 7:GPC 模式缺少 PIN 配置。');
|
||||
}
|
||||
|
||||
await setState({
|
||||
gopayHelperChallengeId: challengeId,
|
||||
gopayHelperFlowId: nextFlowId,
|
||||
gopayHelperGoPayGuid: gopayGuid,
|
||||
gopayHelperRedirectUrl: redirectUrl,
|
||||
});
|
||||
|
||||
await addLog('步骤 7:正在提交 PIN...', 'info');
|
||||
const pinInput = {
|
||||
reference_id: referenceId,
|
||||
challenge_id: challengeId,
|
||||
gopay_guid: gopayGuid,
|
||||
redirect_url: redirectUrl,
|
||||
flow_id: nextFlowId,
|
||||
pin,
|
||||
card_key: cardKey,
|
||||
};
|
||||
const pinResponse = await postGpcJsonWithFallback(
|
||||
apiUrl,
|
||||
'/api/gopay/pin',
|
||||
buildGpcPinPayload(pinInput),
|
||||
buildGpcPinRetryPayload(pinInput),
|
||||
30000
|
||||
);
|
||||
if (!pinResponse?.response?.ok) {
|
||||
throw new Error(`步骤 7:PIN 验证失败:${getGpcResponseErrorDetail(pinResponse?.data, pinResponse?.response?.status || 0)}`);
|
||||
}
|
||||
|
||||
await setState({
|
||||
plusCheckoutSource: PLUS_PAYMENT_METHOD_GPC_HELPER,
|
||||
gopayHelperPinPayload: pinResponse?.data || null,
|
||||
});
|
||||
await addLog('步骤 7:GPC 支付完成,准备继续下一步。', 'ok');
|
||||
await completeStepFromBackground(7, {
|
||||
plusCheckoutSource: PLUS_PAYMENT_METHOD_GPC_HELPER,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveMeiguodizhiCountryCode(value = '') {
|
||||
const normalized = normalizeText(value);
|
||||
const upper = normalized.toUpperCase();
|
||||
@@ -617,6 +908,10 @@
|
||||
}
|
||||
|
||||
async function executePlusCheckoutBilling(state = {}) {
|
||||
if (isGpcHelperCheckout(state)) {
|
||||
await executeGpcHelperBilling(state);
|
||||
return;
|
||||
}
|
||||
const paymentMethod = normalizePlusPaymentMethod(state?.plusPaymentMethod);
|
||||
const paymentConfig = getPaymentMethodConfig(paymentMethod);
|
||||
const tabId = await getCheckoutTabId(state);
|
||||
|
||||
Reference in New Issue
Block a user