feat: add GPC SMS helper functionality with local SMS support
- Introduced GPC OTP channel selection (WhatsApp/SMS) in the sidepanel. - Added local SMS helper toggle and URL input for macOS users. - Implemented normalization functions for OTP channel and local SMS URL. - Updated settings payload to include new GPC helper configurations. - Enhanced Plus checkout process to utilize local SMS helper for OTP retrieval. - Added tests for GPC SMS helper script and its integration in the checkout flow. - Updated documentation to reflect new GPC helper features and configurations.
This commit is contained in:
@@ -78,6 +78,8 @@ test('background account history settings are normalized independently from hotm
|
||||
extractFunction('normalizeFiveSimMaxPrice'),
|
||||
extractFunction('normalizeFiveSimCountryFallback'),
|
||||
extractFunction('normalizeSub2ApiGroupNames'),
|
||||
extractFunction('normalizeBoundedIntegerSetting'),
|
||||
extractFunction('normalizeLocalHttpBaseUrl'),
|
||||
extractFunction('normalizePersistentSettingValue'),
|
||||
].join('\n');
|
||||
|
||||
@@ -197,6 +199,15 @@ return {
|
||||
assert.equal(api.normalizePersistentSettingValue('gopayHelperCountryCode', ' 86 '), '+86');
|
||||
assert.equal(api.normalizePersistentSettingValue('gopayHelperPhoneNumber', ' +86 138-0013-8000 '), '+8613800138000');
|
||||
assert.equal(api.normalizePersistentSettingValue('gopayHelperPin', ' 12-34-56 '), '123456');
|
||||
assert.equal(api.normalizePersistentSettingValue('gopayHelperOtpChannel', 'SMS'), 'sms');
|
||||
assert.equal(api.normalizePersistentSettingValue('gopayHelperOtpChannel', 'unknown'), 'whatsapp');
|
||||
assert.equal(api.normalizePersistentSettingValue('gopayHelperLocalSmsHelperEnabled', 1), true);
|
||||
assert.equal(
|
||||
api.normalizePersistentSettingValue('gopayHelperLocalSmsHelperUrl', 'http://127.0.0.1:18767/otp?x=1'),
|
||||
'http://127.0.0.1:18767'
|
||||
);
|
||||
assert.equal(api.normalizePersistentSettingValue('gopayHelperLocalSmsTimeoutSeconds', '999'), 300);
|
||||
assert.equal(api.normalizePersistentSettingValue('gopayHelperLocalSmsPollIntervalSeconds', '0'), 1);
|
||||
assert.equal(api.normalizePersistentSettingValue('verificationResendCount', '7'), 7);
|
||||
assert.equal(api.normalizePersistentSettingValue('verificationResendCount', '-1'), 0);
|
||||
assert.equal(api.normalizePersistentSettingValue('phoneVerificationReplacementLimit', '9'), 9);
|
||||
|
||||
@@ -12,6 +12,9 @@ test('GoPay utils normalize manual OTP input', () => {
|
||||
const api = loadGoPayUtils();
|
||||
assert.equal(api.normalizeGoPayOtp(' 12-34 56 '), '123456');
|
||||
assert.equal(api.normalizeGoPayOtp('abc'), '');
|
||||
assert.equal(api.normalizeGpcOtpChannel('sms'), 'sms');
|
||||
assert.equal(api.normalizeGpcOtpChannel('wa'), 'whatsapp');
|
||||
assert.equal(api.normalizeGpcOtpChannel('unknown'), 'whatsapp');
|
||||
});
|
||||
|
||||
test('GoPay utils keeps GPC helper payment method distinct', () => {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
|
||||
const scriptPath = path.join(process.cwd(), 'scripts/gpc_sms_helper_macos.py');
|
||||
|
||||
function runPython(args, options = {}) {
|
||||
for (const command of ['python3', 'python']) {
|
||||
const result = spawnSync(command, args, {
|
||||
encoding: 'utf8',
|
||||
...options,
|
||||
env: {
|
||||
...process.env,
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
...(options.env || {}),
|
||||
},
|
||||
});
|
||||
if (result.error && result.error.code === 'ENOENT') {
|
||||
continue;
|
||||
}
|
||||
if (process.platform === 'win32' && result.status === 9009) {
|
||||
continue;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
test('GPC SMS helper shows macOS and iPhone forwarding guidance on non-macOS', () => {
|
||||
const help = runPython([scriptPath, '--help']);
|
||||
if (!help) {
|
||||
return;
|
||||
}
|
||||
assert.equal(help.status, 0);
|
||||
if (process.platform === 'darwin') {
|
||||
return;
|
||||
}
|
||||
const run = runPython([scriptPath, '--db', '/tmp/nonexistent-gpc-chat.db'], {
|
||||
timeout: 3000,
|
||||
});
|
||||
assert.ok(run);
|
||||
assert.notEqual(run.status, 0);
|
||||
assert.match(run.stderr, /仅支持 macOS/);
|
||||
assert.match(run.stderr, /iPhone 短信已转发|短信转发/);
|
||||
});
|
||||
|
||||
test('GPC SMS helper filters cached OTP records by order timestamp', () => {
|
||||
const code = `
|
||||
import importlib.util
|
||||
import json
|
||||
|
||||
script_path = ${JSON.stringify(scriptPath)}
|
||||
spec = importlib.util.spec_from_file_location("gpc_sms_helper_macos", script_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
old = {"otp": "111111", "code": "111111", "message_id": "old", "received_at": "2026-05-05T00:00:00+00:00"}
|
||||
fresh = {"otp": "222222", "code": "222222", "message_id": "fresh", "received_at": "2026-05-05T00:00:10+00:00"}
|
||||
state = {"last_otp": fresh, "otps": [fresh, old]}
|
||||
payload = {
|
||||
"without_filter": module.select_otp_record(state, 0)["otp"],
|
||||
"fresh_only": module.select_otp_record(state, module.parse_timestamp_ms("2026-05-05T00:00:05+00:00"))["otp"],
|
||||
"none_after_fresh": module.select_otp_record(state, module.parse_timestamp_ms("2026-05-05T00:00:11+00:00")) is None,
|
||||
}
|
||||
print(json.dumps(payload))
|
||||
`;
|
||||
const run = runPython(['-c', code], {
|
||||
timeout: 3000,
|
||||
env: { GPC_SMS_HELPER_ALLOW_NON_MAC: '1' },
|
||||
});
|
||||
if (!run) {
|
||||
return;
|
||||
}
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
assert.deepEqual(JSON.parse(run.stdout.trim()), {
|
||||
without_filter: '222222',
|
||||
fresh_only: '222222',
|
||||
none_after_fresh: true,
|
||||
});
|
||||
});
|
||||
@@ -871,6 +871,67 @@ test('GPC billing normalizes API URL and submits OTP then PIN with card_key and
|
||||
assert.equal(events.completed[0].payload.plusCheckoutSource, 'gpc-helper');
|
||||
});
|
||||
|
||||
test('GPC billing reads OTP from local SMS helper when enabled', async () => {
|
||||
const fetchCalls = [];
|
||||
const { events, executor } = createExecutorHarness({
|
||||
frames: [],
|
||||
stateByFrame: {},
|
||||
fetchImpl: async (url, options = {}) => {
|
||||
fetchCalls.push({ url, options });
|
||||
if (url.startsWith('http://127.0.0.1:18767/otp')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true, otp: '654321', message_id: 'sms-1' }),
|
||||
};
|
||||
}
|
||||
if (url.endsWith('/api/gopay/otp')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ reference_id: 'ref_sms', challenge_id: 'challenge_sms' }),
|
||||
};
|
||||
}
|
||||
if (url.endsWith('/api/gopay/pin')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ stage: 'gopay_complete' }),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected url: ${url}`);
|
||||
},
|
||||
});
|
||||
|
||||
await executor.executePlusCheckoutBilling({
|
||||
plusPaymentMethod: 'gpc-helper',
|
||||
plusCheckoutSource: 'gpc-helper',
|
||||
gopayHelperReferenceId: 'ref_sms',
|
||||
gopayHelperApiUrl: 'https://gopay.hwork.pro/',
|
||||
gopayHelperPin: '654321',
|
||||
gopayHelperCardKey: 'card_sms',
|
||||
gopayHelperOtpChannel: 'sms',
|
||||
gopayHelperLocalSmsHelperEnabled: true,
|
||||
gopayHelperLocalSmsHelperUrl: 'http://127.0.0.1:18767',
|
||||
gopayHelperPhoneNumber: '+8613800138000',
|
||||
gopayHelperOrderCreatedAt: 1710000000000,
|
||||
});
|
||||
|
||||
assert.equal(events.states.some((state) => state.plusManualConfirmationMethod === 'gopay-otp'), false);
|
||||
assert.equal(events.states.some((state) => state.gopayHelperResolvedOtp === '654321'), true);
|
||||
const helperUrl = new URL(fetchCalls[0].url);
|
||||
assert.equal(helperUrl.origin + helperUrl.pathname, 'http://127.0.0.1:18767/otp');
|
||||
assert.equal(helperUrl.searchParams.get('reference_id'), 'ref_sms');
|
||||
assert.equal(helperUrl.searchParams.get('phone_number'), '+8613800138000');
|
||||
assert.equal(helperUrl.searchParams.get('after_ms'), '1710000000000');
|
||||
assert.deepEqual(JSON.parse(fetchCalls[1].options.body), {
|
||||
reference_id: 'ref_sms',
|
||||
otp: '654321',
|
||||
card_key: 'card_sms',
|
||||
});
|
||||
assert.equal(events.completed[0].step, 7);
|
||||
});
|
||||
|
||||
test('GPC billing retries OTP with compatibility field after HTTP 400', async () => {
|
||||
const fetchCalls = [];
|
||||
let currentState = {
|
||||
|
||||
@@ -175,14 +175,60 @@ test('GPC checkout injects Plus script before reading ChatGPT session token and
|
||||
type: 'gopay',
|
||||
country_code: '86',
|
||||
phone_number: '13800138000',
|
||||
phone_mode: 'manual',
|
||||
otp_channel: 'whatsapp',
|
||||
});
|
||||
assert.equal(events.find((event) => event.type === 'set-state')?.payload?.plusCheckoutSource, 'gpc-helper');
|
||||
assert.equal(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperReferenceId, 'ref_123');
|
||||
assert.equal(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperFlowId, 'flow_789');
|
||||
assert.ok(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperOrderCreatedAt > 0);
|
||||
assert.equal(events.find((event) => event.type === 'complete')?.step, 6);
|
||||
assert.equal(events.find((event) => event.type === 'complete')?.payload?.plusCheckoutSource, 'gpc-helper');
|
||||
});
|
||||
|
||||
test('GPC checkout forwards selected SMS OTP channel', async () => {
|
||||
const fetchCalls = [];
|
||||
const executor = api.createPlusCheckoutCreateExecutor({
|
||||
addLog: async () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
create: async () => ({ id: 88 }),
|
||||
remove: async () => {},
|
||||
},
|
||||
},
|
||||
completeStepFromBackground: async () => {},
|
||||
ensureContentScriptReadyOnTabUntilStopped: async () => {},
|
||||
fetch: async (url, options = {}) => {
|
||||
fetchCalls.push({ url, options });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ reference_id: 'ref_sms', next_action: 'enter_otp' }),
|
||||
};
|
||||
},
|
||||
registerTab: async () => {},
|
||||
sendTabMessageUntilStopped: async () => ({ accessToken: 'session-access-token' }),
|
||||
setState: async () => {},
|
||||
sleepWithStop: async () => {},
|
||||
waitForTabCompleteUntilStopped: async () => {},
|
||||
});
|
||||
|
||||
await executor.executePlusCheckoutCreate({
|
||||
email: 'sms@example.com',
|
||||
plusPaymentMethod: 'gpc-helper',
|
||||
gopayHelperApiUrl: 'https://gopay.hwork.pro/',
|
||||
gopayHelperPhoneNumber: '+8613800138000',
|
||||
gopayHelperCountryCode: '+86',
|
||||
gopayHelperPin: '123456',
|
||||
gopayHelperCardKey: 'card_sms',
|
||||
gopayHelperOtpChannel: 'sms',
|
||||
});
|
||||
|
||||
const helperPayload = JSON.parse(fetchCalls[0].options.body);
|
||||
assert.equal(helperPayload.gopay_link.phone_mode, 'manual');
|
||||
assert.equal(helperPayload.gopay_link.otp_channel, 'sms');
|
||||
});
|
||||
|
||||
test('GPC checkout treats non-zero API amount as non-free-trial and does not create order', async () => {
|
||||
const markCalls = [];
|
||||
const fetchCalls = [];
|
||||
|
||||
@@ -132,6 +132,7 @@ test('sidepanel Plus UI hides PayPal account selector while GoPay is selected',
|
||||
const bundle = [
|
||||
extractFunction('normalizePlusPaymentMethod'),
|
||||
extractFunction('getSelectedPlusPaymentMethod'),
|
||||
extractFunction('normalizeGpcOtpChannelValue'),
|
||||
extractFunction('updatePlusModeUI'),
|
||||
].join('\n');
|
||||
|
||||
@@ -208,6 +209,7 @@ test('sidepanel Plus UI shows GPC fields and purchase button only for GPC withou
|
||||
const bundle = [
|
||||
extractFunction('normalizePlusPaymentMethod'),
|
||||
extractFunction('getSelectedPlusPaymentMethod'),
|
||||
extractFunction('normalizeGpcOtpChannelValue'),
|
||||
extractFunction('updatePlusModeUI'),
|
||||
].join('\n');
|
||||
|
||||
@@ -224,6 +226,11 @@ const rowGpcHelperApi = { style: { display: 'none' } };
|
||||
const rowGpcHelperCardKey = { style: { display: 'none' } };
|
||||
const rowGpcHelperCountryCode = { style: { display: 'none' } };
|
||||
const rowGpcHelperPhone = { style: { display: 'none' } };
|
||||
const rowGpcHelperOtpChannel = { style: { display: 'none' } };
|
||||
const selectGpcHelperOtpChannel = { value: 'whatsapp' };
|
||||
const rowGpcHelperLocalSmsEnabled = { style: { display: 'none' } };
|
||||
const inputGpcHelperLocalSmsEnabled = { checked: false };
|
||||
const rowGpcHelperLocalSmsUrl = { style: { display: 'none' } };
|
||||
const rowGpcHelperPin = { style: { display: 'none' } };
|
||||
const rowGoPayCountryCode = { style: { display: 'none' } };
|
||||
const rowGoPayPhone = { style: { display: 'none' } };
|
||||
@@ -233,10 +240,12 @@ ${bundle}
|
||||
return {
|
||||
updatePlusModeUI,
|
||||
selectPlusPaymentMethod,
|
||||
selectGpcHelperOtpChannel,
|
||||
inputGpcHelperLocalSmsEnabled,
|
||||
btnGpcCardKeyPurchase,
|
||||
rowPayPalAccount,
|
||||
plusPaymentMethodCaption,
|
||||
rows: { rowGpcHelperApi, rowGpcHelperCardKey, rowGpcHelperCountryCode, rowGpcHelperPhone, rowGpcHelperPin },
|
||||
rows: { rowGpcHelperApi, rowGpcHelperCardKey, rowGpcHelperCountryCode, rowGpcHelperPhone, rowGpcHelperOtpChannel, rowGpcHelperLocalSmsEnabled, rowGpcHelperLocalSmsUrl, rowGpcHelperPin },
|
||||
};
|
||||
`)();
|
||||
|
||||
@@ -247,8 +256,26 @@ return {
|
||||
assert.equal(api.rows.rowGpcHelperApi.style.display, 'none');
|
||||
assert.equal(api.rows.rowGpcHelperCardKey.style.display, '');
|
||||
assert.equal(api.rows.rowGpcHelperPhone.style.display, '');
|
||||
assert.equal(api.rows.rowGpcHelperOtpChannel.style.display, '');
|
||||
assert.equal(api.rows.rowGpcHelperLocalSmsEnabled.style.display, 'none');
|
||||
assert.equal(api.rows.rowGpcHelperLocalSmsUrl.style.display, 'none');
|
||||
assert.match(api.plusPaymentMethodCaption.textContent, /GPC/);
|
||||
|
||||
api.selectGpcHelperOtpChannel.value = 'sms';
|
||||
api.updatePlusModeUI();
|
||||
assert.equal(api.rows.rowGpcHelperLocalSmsEnabled.style.display, '');
|
||||
assert.equal(api.rows.rowGpcHelperLocalSmsUrl.style.display, 'none');
|
||||
|
||||
api.inputGpcHelperLocalSmsEnabled.checked = true;
|
||||
api.updatePlusModeUI();
|
||||
assert.equal(api.rows.rowGpcHelperLocalSmsUrl.style.display, '');
|
||||
|
||||
api.selectGpcHelperOtpChannel.value = 'whatsapp';
|
||||
api.updatePlusModeUI();
|
||||
assert.equal(api.inputGpcHelperLocalSmsEnabled.checked, false);
|
||||
assert.equal(api.rows.rowGpcHelperLocalSmsEnabled.style.display, 'none');
|
||||
assert.equal(api.rows.rowGpcHelperLocalSmsUrl.style.display, 'none');
|
||||
|
||||
api.selectPlusPaymentMethod.value = 'gopay';
|
||||
api.updatePlusModeUI();
|
||||
assert.equal(api.btnGpcCardKeyPurchase.style.display, 'none');
|
||||
|
||||
@@ -141,6 +141,9 @@ test('sidepanel html exposes Plus mode, PayPal, and GoPay settings', () => {
|
||||
assert.match(html, /id="input-gpc-helper-card-key"/);
|
||||
assert.match(html, /id="btn-gpc-helper-balance"/);
|
||||
assert.match(html, /id="input-gpc-helper-phone"/);
|
||||
assert.match(html, /id="select-gpc-helper-otp-channel"/);
|
||||
assert.match(html, /id="input-gpc-helper-local-sms-enabled"/);
|
||||
assert.match(html, /id="input-gpc-helper-local-sms-url"/);
|
||||
assert.match(html, /id="input-gpc-helper-pin"/);
|
||||
assert.match(html, /id="shared-form-modal"/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user