From aa57e0f645497212033e1aa82265bbd24928e22f Mon Sep 17 00:00:00 2001
From: QLHazyCoder <2825305047@qq.com>
Date: Wed, 6 May 2026 01:39:11 +0800
Subject: [PATCH] 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.
---
background.js | 63 ++++
background/steps/create-plus-checkout.js | 23 +-
background/steps/fill-plus-checkout.js | 159 ++++++++-
gopay-utils.js | 9 +
scripts/gpc_sms_helper_macos.py | 313 ++++++++++++++++++
sidepanel/sidepanel.html | 22 ++
sidepanel/sidepanel.js | 170 +++++++++-
...ackground-account-history-settings.test.js | 11 +
tests/gopay-utils.test.js | 3 +
tests/gpc-sms-helper-script.test.js | 81 +++++
...us-checkout-billing-tab-resolution.test.js | 61 ++++
tests/plus-checkout-create-wait.test.js | 46 +++
tests/sidepanel-plus-payment-method.test.js | 29 +-
tests/step-definitions-module.test.js | 3 +
项目完整链路说明.md | 8 +-
项目文件结构说明.md | 6 +-
16 files changed, 980 insertions(+), 27 deletions(-)
create mode 100644 scripts/gpc_sms_helper_macos.py
create mode 100644 tests/gpc-sms-helper-script.test.js
diff --git a/background.js b/background.js
index 8b6e64d..14bb910 100644
--- a/background.js
+++ b/background.js
@@ -609,6 +609,11 @@ const PERSISTED_SETTING_DEFAULTS = {
gopayHelperPhoneNumber: '',
gopayHelperCountryCode: '+86',
gopayHelperPin: '',
+ gopayHelperOtpChannel: 'whatsapp',
+ gopayHelperLocalSmsHelperEnabled: false,
+ gopayHelperLocalSmsHelperUrl: 'http://127.0.0.1:18767',
+ gopayHelperLocalSmsTimeoutSeconds: 90,
+ gopayHelperLocalSmsPollIntervalSeconds: 2,
gopayHelperReferenceId: '',
gopayHelperGoPayGuid: '',
gopayHelperRedirectUrl: '',
@@ -774,6 +779,7 @@ const DEFAULT_STATE = {
gopayHelperFlowId: '',
gopayHelperChallengeId: '',
gopayHelperStartPayload: null,
+ gopayHelperOrderCreatedAt: 0,
gopayHelperPinPayload: null,
gopayHelperResolvedOtp: '',
gopayHelperOtpRequestId: '',
@@ -1015,6 +1021,38 @@ function normalizePhoneCodePollMaxRounds(value, fallback = DEFAULT_PHONE_CODE_PO
);
}
+function normalizeBoundedIntegerSetting(value, fallback, min, max) {
+ const rawValue = String(value ?? '').trim();
+ const numeric = Number(rawValue);
+ const fallbackNumeric = Number(fallback);
+ const normalizedFallback = Number.isFinite(fallbackNumeric)
+ ? Math.min(max, Math.max(min, Math.floor(fallbackNumeric)))
+ : min;
+ if (!rawValue || !Number.isFinite(numeric)) {
+ return normalizedFallback;
+ }
+ return Math.min(max, Math.max(min, Math.floor(numeric)));
+}
+
+function normalizeLocalHttpBaseUrl(value = '', fallback = 'http://127.0.0.1:18767') {
+ const rawValue = String(value || fallback).trim();
+ try {
+ const parsed = new URL(rawValue);
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
+ return fallback;
+ }
+ const endpointPath = parsed.pathname.replace(/\/+$/g, '') || '/';
+ if (['/otp', '/latest-otp', '/health'].includes(endpointPath)) {
+ parsed.pathname = '';
+ parsed.search = '';
+ parsed.hash = '';
+ }
+ return parsed.toString().replace(/\/$/, '');
+ } catch {
+ return fallback;
+ }
+}
+
function normalizeHeroSmsMaxPrice(value = '') {
const rawValue = String(value ?? '').trim();
if (!rawValue) {
@@ -2213,6 +2251,29 @@ function normalizePersistentSettingValue(key, value) {
return self.GoPayUtils?.normalizeGoPayCountryCode
? self.GoPayUtils.normalizeGoPayCountryCode(value)
: String(value || '+86').trim();
+ case 'gopayHelperOtpChannel':
+ return self.GoPayUtils?.normalizeGpcOtpChannel
+ ? self.GoPayUtils.normalizeGpcOtpChannel(value)
+ : (String(value || '').trim().toLowerCase() === 'sms' ? 'sms' : 'whatsapp');
+ case 'gopayHelperLocalSmsHelperUrl':
+ return normalizeLocalHttpBaseUrl(
+ value,
+ PERSISTED_SETTING_DEFAULTS.gopayHelperLocalSmsHelperUrl || 'http://127.0.0.1:18767'
+ );
+ case 'gopayHelperLocalSmsTimeoutSeconds':
+ return normalizeBoundedIntegerSetting(
+ value,
+ PERSISTED_SETTING_DEFAULTS.gopayHelperLocalSmsTimeoutSeconds,
+ 10,
+ 300
+ );
+ case 'gopayHelperLocalSmsPollIntervalSeconds':
+ return normalizeBoundedIntegerSetting(
+ value,
+ PERSISTED_SETTING_DEFAULTS.gopayHelperLocalSmsPollIntervalSeconds,
+ 1,
+ 30
+ );
case 'gopayHelperApiUrl':
{
const defaultGpcHelperApiUrl = PERSISTED_SETTING_DEFAULTS.gopayHelperApiUrl
@@ -2238,6 +2299,7 @@ function normalizePersistentSettingValue(key, value) {
return Math.max(0, Number(value) || 0);
case 'autoRunSkipFailures':
case 'oauthFlowTimeoutEnabled':
+ case 'gopayHelperLocalSmsHelperEnabled':
case 'autoRunDelayEnabled':
case 'phoneVerificationEnabled':
case 'plusModeEnabled':
@@ -7349,6 +7411,7 @@ function getDownstreamStateResets(step, state = {}) {
gopayHelperFlowId: '',
gopayHelperChallengeId: '',
gopayHelperStartPayload: null,
+ gopayHelperOrderCreatedAt: 0,
gopayHelperPinPayload: null,
gopayHelperResolvedOtp: '',
gopayHelperOtpRequestId: '',
diff --git a/background/steps/create-plus-checkout.js b/background/steps/create-plus-checkout.js
index 8e1b67b..4914884 100644
--- a/background/steps/create-plus-checkout.js
+++ b/background/steps/create-plus-checkout.js
@@ -11,7 +11,7 @@
function createPlusCheckoutCreateExecutor(deps = {}) {
const {
- addLog,
+ addLog: rawAddLog = async () => {},
chrome,
completeStepFromBackground,
ensureContentScriptReadyOnTabUntilStopped,
@@ -25,6 +25,14 @@
throwIfStopped = () => {},
} = deps;
+ function addLog(message, level = 'info', options = {}) {
+ return rawAddLog(message, level, {
+ step: 6,
+ stepKey: 'plus-checkout-create',
+ ...(options && typeof options === 'object' ? options : {}),
+ });
+ }
+
function normalizePlusPaymentMethod(value = '') {
const rootScope = typeof self !== 'undefined' ? self : globalThis;
if (rootScope.GoPayUtils?.normalizePlusPaymentMethod) {
@@ -79,6 +87,14 @@
return cleaned;
}
+ function normalizeGpcOtpChannel(value = '') {
+ const rootScope = typeof self !== 'undefined' ? self : globalThis;
+ if (rootScope.GoPayUtils?.normalizeGpcOtpChannel) {
+ return rootScope.GoPayUtils.normalizeGpcOtpChannel(value);
+ }
+ return String(value || '').trim().toLowerCase() === 'sms' ? 'sms' : 'whatsapp';
+ }
+
function resolveGpcHelperCardKey(state = {}) {
const cardKey = String(state?.gopayHelperCardKey || state?.gpcCardKey || state?.cardKey || '').trim();
if (!cardKey) {
@@ -307,9 +323,12 @@
type: 'gopay',
country_code: countryCode,
phone_number: normalizeHelperPhoneNumber(phoneNumber, countryCode),
+ phone_mode: 'manual',
+ otp_channel: normalizeGpcOtpChannel(state?.gopayHelperOtpChannel),
},
};
+ const orderCreatedAt = Date.now();
const { response, data } = await fetchJsonWithTimeout(apiUrl, {
method: 'POST',
headers: {
@@ -346,6 +365,7 @@
nextAction,
flowId,
challengeId,
+ orderCreatedAt,
responsePayload: data && typeof data === 'object' && !Array.isArray(data) ? data : null,
country: 'ID',
currency: 'IDR',
@@ -385,6 +405,7 @@
gopayHelperFlowId: result.flowId,
gopayHelperChallengeId: result.challengeId,
gopayHelperStartPayload: result.responsePayload,
+ gopayHelperOrderCreatedAt: result.orderCreatedAt || Date.now(),
});
await addLog('步骤 6:GPC 订单已创建,准备继续下一步。', 'info');
await completeStepFromBackground(6, {
diff --git a/background/steps/fill-plus-checkout.js b/background/steps/fill-plus-checkout.js
index 692b2dd..89133ec 100644
--- a/background/steps/fill-plus-checkout.js
+++ b/background/steps/fill-plus-checkout.js
@@ -54,7 +54,7 @@
function createPlusCheckoutBillingExecutor(deps = {}) {
const {
- addLog,
+ addLog: rawAddLog = async () => {},
broadcastDataUpdate,
chrome,
completeStepFromBackground,
@@ -73,6 +73,14 @@
throwIfStopped = () => {},
} = deps;
+ function addLog(message, level = 'info', options = {}) {
+ return rawAddLog(message, level, {
+ step: 7,
+ stepKey: 'plus-checkout-billing',
+ ...(options && typeof options === 'object' ? options : {}),
+ });
+ }
+
function isPlusCheckoutUrl(url = '') {
return PLUS_CHECKOUT_URL_PATTERN.test(String(url || ''));
}
@@ -238,6 +246,129 @@
return Promise.resolve({});
}
+ function normalizeLocalSmsHelperBaseUrl(value = '') {
+ const fallback = 'http://127.0.0.1:18767';
+ const rawValue = String(value || fallback).trim();
+ try {
+ const parsed = new URL(rawValue);
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
+ return fallback;
+ }
+ const endpointPath = parsed.pathname.replace(/\/+$/g, '') || '/';
+ if (['/otp', '/latest-otp', '/health'].includes(endpointPath)) {
+ parsed.pathname = '';
+ parsed.search = '';
+ parsed.hash = '';
+ }
+ return parsed.toString().replace(/\/$/, '');
+ } catch {
+ return fallback;
+ }
+ }
+
+ function normalizeIncomingGpcSmsOtp(payload = {}) {
+ const candidates = [
+ payload?.otp,
+ payload?.code,
+ payload?.sms_code,
+ payload?.smsCode,
+ payload?.verification_code,
+ payload?.verificationCode,
+ ];
+ for (const candidate of candidates) {
+ const normalized = String(candidate || '').trim().replace(/[^\d]/g, '');
+ if (/^\d{4,8}$/.test(normalized)) {
+ return normalized;
+ }
+ }
+ const messageText = String(payload?.message_text || payload?.messageText || payload?.text || '').trim();
+ if (messageText) {
+ const match = messageText.match(/(?:OTP\s*[::]?\s*|#)(\d{4,8})\b|\b(\d{6})\b/i);
+ if (match) {
+ return String(match[1] || match[2] || '').trim();
+ }
+ }
+ return '';
+ }
+
+ function normalizeGpcOtpChannel(value = '') {
+ const rootScope = typeof self !== 'undefined' ? self : globalThis;
+ if (rootScope.GoPayUtils?.normalizeGpcOtpChannel) {
+ return rootScope.GoPayUtils.normalizeGpcOtpChannel(value);
+ }
+ return String(value || '').trim().toLowerCase() === 'sms' ? 'sms' : 'whatsapp';
+ }
+
+ function normalizeEpochMilliseconds(value = 0) {
+ const rawValue = String(value ?? '').trim();
+ if (!rawValue) {
+ return 0;
+ }
+ const numeric = Number(rawValue);
+ if (Number.isFinite(numeric) && numeric > 0) {
+ return Math.floor(numeric < 100000000000 ? numeric * 1000 : numeric);
+ }
+ const parsed = Date.parse(rawValue);
+ return Number.isFinite(parsed) ? parsed : 0;
+ }
+
+ function buildLocalSmsHelperOtpUrl(state = {}, referenceId = '') {
+ const baseUrl = normalizeLocalSmsHelperBaseUrl(state?.gopayHelperLocalSmsHelperUrl);
+ const url = new URL(`${baseUrl}/otp`);
+ const normalizedReferenceId = String(referenceId || '').trim();
+ const phoneNumber = String(state?.gopayHelperPhoneNumber || '').trim();
+ const orderCreatedAt = normalizeEpochMilliseconds(
+ state?.gopayHelperOrderCreatedAt
+ || state?.gopayHelperStartPayload?.order_created_at
+ || state?.gopayHelperStartPayload?.orderCreatedAt
+ || state?.gopayHelperStartPayload?.created_at
+ || state?.gopayHelperStartPayload?.createdAt
+ );
+ if (normalizedReferenceId) {
+ url.searchParams.set('reference_id', normalizedReferenceId);
+ }
+ if (phoneNumber) {
+ url.searchParams.set('phone_number', phoneNumber);
+ }
+ if (orderCreatedAt > 0) {
+ url.searchParams.set('after_ms', String(orderCreatedAt));
+ }
+ return url.toString();
+ }
+
+ async function pollLocalSmsHelperOtp(state = {}, referenceId = '') {
+ const timeoutSeconds = Math.max(10, Math.min(300, Number(state?.gopayHelperLocalSmsTimeoutSeconds) || 90));
+ const pollIntervalSeconds = Math.max(1, Math.min(30, Number(state?.gopayHelperLocalSmsPollIntervalSeconds) || 2));
+ const deadline = Date.now() + timeoutSeconds * 1000;
+ const requestUrl = buildLocalSmsHelperOtpUrl(state, referenceId);
+ let lastMessage = '';
+ while (Date.now() <= deadline) {
+ throwIfStopped();
+ try {
+ const { response, data } = await fetchJsonWithTimeout(requestUrl, {
+ method: 'GET',
+ headers: { Accept: 'application/json' },
+ }, Math.min(8000, Math.max(1000, pollIntervalSeconds * 1000)));
+ const otp = normalizeIncomingGpcSmsOtp(data || {});
+ if (response?.ok && otp) {
+ await setState({
+ gopayHelperResolvedOtp: otp,
+ gopayHelperSmsOtpPayload: data && typeof data === 'object' && !Array.isArray(data) ? data : null,
+ });
+ if (typeof broadcastDataUpdate === 'function') {
+ broadcastDataUpdate({ gopayHelperResolvedOtp: otp });
+ }
+ return otp;
+ }
+ lastMessage = String(data?.message || data?.status || '').trim();
+ } catch (error) {
+ lastMessage = error?.message || String(error || '未知错误');
+ }
+ await sleepWithStop(pollIntervalSeconds * 1000);
+ }
+ throw new Error(lastMessage || '本地 SMS Helper 等待 OTP 超时。');
+ }
+
async function requestGpcOtpInput({ title = '', message = '', referenceId = '' }) {
const requestId = `otp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const payload = {
@@ -291,12 +422,26 @@
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,
- });
+ let otp = '';
+ const useLocalSmsHelper = Boolean(state?.gopayHelperLocalSmsHelperEnabled)
+ && normalizeGpcOtpChannel(state?.gopayHelperOtpChannel) === 'sms';
+ if (useLocalSmsHelper) {
+ try {
+ await addLog('步骤 7:正在从本地 SMS Helper 等待 GPC OTP...', 'info');
+ otp = await pollLocalSmsHelperOtp(state, referenceId);
+ await addLog('步骤 7:本地 SMS Helper 已读取到 GPC OTP,准备提交验证。', 'ok');
+ } catch (error) {
+ await addLog(`步骤 7:本地 SMS Helper 未能自动读取 OTP:${error?.message || String(error || '未知错误')},改为手动输入。`, 'warn');
+ }
+ }
+ if (!otp) {
+ await addLog('步骤 7:等待用户输入 OTP...', 'info');
+ otp = await requestGpcOtpInput({
+ title: 'GPC OTP 验证',
+ message: `请输入收到的 OTP 验证码(reference_id: ${referenceId})`,
+ referenceId,
+ });
+ }
const flowId = state?.gopayHelperFlowId
|| state?.gopayHelperStartPayload?.flow_id
diff --git a/gopay-utils.js b/gopay-utils.js
index 462f034..53eb4c1 100644
--- a/gopay-utils.js
+++ b/gopay-utils.js
@@ -46,6 +46,14 @@
return String(value || '').trim().replace(/[^\d]/g, '');
}
+ function normalizeGpcOtpChannel(value = '') {
+ const normalized = String(value || '').trim().toLowerCase();
+ if (normalized === 'sms') {
+ return 'sms';
+ }
+ return 'whatsapp';
+ }
+
function normalizeGpcHelperBaseUrl(apiUrl = '') {
let normalized = String(apiUrl || DEFAULT_GPC_HELPER_API_URL).trim();
if (!normalized) {
@@ -213,6 +221,7 @@
normalizeGoPayPhoneForCountry,
normalizeGoPayOtp,
normalizeGoPayPin,
+ normalizeGpcOtpChannel,
normalizePlusPaymentMethod,
};
});
diff --git a/scripts/gpc_sms_helper_macos.py b/scripts/gpc_sms_helper_macos.py
new file mode 100644
index 0000000..65092b9
--- /dev/null
+++ b/scripts/gpc_sms_helper_macos.py
@@ -0,0 +1,313 @@
+#!/usr/bin/env python3
+"""Local macOS SMS OTP helper for GPC GoPay flows.
+
+Run this on the Mac that receives forwarded iPhone SMS messages. The helper
+reads a copy of the macOS Messages database and exposes the latest matching OTP
+on a localhost HTTP endpoint for the Chrome extension.
+"""
+
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import json
+import os
+import platform
+import re
+import shutil
+import sqlite3
+import sys
+import tempfile
+import threading
+import time
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from typing import Optional
+from urllib.parse import parse_qs, urlparse
+
+HOST = "127.0.0.1"
+PORT = 18767
+DEFAULT_DB = "~/Library/Messages/chat.db"
+MAC_ABSOLUTE_EPOCH = dt.datetime(2001, 1, 1, tzinfo=dt.timezone.utc)
+
+OTP_PATTERNS = (
+ re.compile(r"(?i)\bOTP\s*[::]?\s*([0-9]{4,8})\b"),
+ re.compile(r"#([0-9]{4,8})\b"),
+ re.compile(r"(? bool:
+ override = os.environ.get("GPC_SMS_HELPER_ALLOW_NON_MAC", "").strip().lower()
+ return platform.system() == "Darwin" or override in {"1", "true", "yes"}
+
+
+def require_macos() -> None:
+ if not is_macos():
+ raise RuntimeError(
+ "GPC 本地 SMS Helper 仅支持 macOS:需要读取 ~/Library/Messages/chat.db。"
+ "请确认接收验证码的 iPhone 已开启短信转发,并能在 Mac 信息 app 中看到短信。"
+ )
+
+
+def extract_gopay_otp(text: str, require_keywords: bool = True) -> Optional[str]:
+ raw = text or ""
+ lowered = raw.lower()
+ if require_keywords and not any(keyword in lowered for keyword in KEYWORDS):
+ return None
+ for pattern in OTP_PATTERNS:
+ match = pattern.search(raw)
+ if match:
+ return match.group(1)
+ return None
+
+
+def mac_message_time_to_datetime(value: int | float | None) -> dt.datetime:
+ if not value:
+ return dt.datetime.now(dt.timezone.utc)
+ seconds = float(value)
+ if seconds > 10_000_000_000:
+ seconds = seconds / 1_000_000_000
+ return MAC_ABSOLUTE_EPOCH + dt.timedelta(seconds=seconds)
+
+
+def update_state(**updates: object) -> None:
+ with STATE_LOCK:
+ STATE.update(updates)
+
+
+def get_state() -> dict:
+ with STATE_LOCK:
+ return dict(STATE)
+
+
+def copy_messages_db(db_path: Path) -> Path:
+ if not db_path.exists():
+ raise FileNotFoundError(f"Messages 数据库不存在:{db_path}")
+ tmpdir = Path(tempfile.mkdtemp(prefix="gpc_messages_"))
+ copied = tmpdir / "chat.db"
+ shutil.copy2(db_path, copied)
+ wal = db_path.with_name(db_path.name + "-wal")
+ shm = db_path.with_name(db_path.name + "-shm")
+ if wal.exists():
+ shutil.copy2(wal, copied.with_name("chat.db-wal"))
+ if shm.exists():
+ shutil.copy2(shm, copied.with_name("chat.db-shm"))
+ return copied
+
+
+def read_recent_messages(db_path: Path, after_rowid: int = 0, limit: int = 80) -> list[dict]:
+ copied = copy_messages_db(db_path)
+ try:
+ conn = sqlite3.connect(str(copied))
+ conn.row_factory = sqlite3.Row
+ rows = conn.execute(
+ """
+ SELECT
+ message.ROWID AS rowid,
+ message.guid AS guid,
+ message.text AS text,
+ message.date AS date,
+ message.service AS service,
+ handle.id AS handle
+ FROM message
+ LEFT JOIN handle ON message.handle_id = handle.ROWID
+ WHERE message.ROWID > ?
+ AND message.text IS NOT NULL
+ ORDER BY message.ROWID DESC
+ LIMIT ?
+ """,
+ (max(0, int(after_rowid or 0)), max(1, int(limit or 80))),
+ ).fetchall()
+ return [dict(row) for row in rows]
+ finally:
+ try:
+ conn.close() # type: ignore[name-defined]
+ except Exception:
+ pass
+ shutil.rmtree(copied.parent, ignore_errors=True)
+
+
+def make_otp_record(row: dict, otp: str) -> dict:
+ received_at = mac_message_time_to_datetime(row.get("date")).isoformat()
+ return {
+ "otp": otp,
+ "code": otp,
+ "message_id": str(row.get("guid") or row.get("rowid") or ""),
+ "rowid": int(row.get("rowid") or 0),
+ "sender": str(row.get("handle") or ""),
+ "service": str(row.get("service") or ""),
+ "received_at": received_at,
+ "message_text": str(row.get("text") or ""),
+ }
+
+
+def append_otp(record: dict, max_records: int = 30) -> None:
+ with STATE_LOCK:
+ records = [item for item in STATE.get("otps", []) if item.get("message_id") != record.get("message_id")]
+ records.insert(0, record)
+ STATE["otps"] = records[:max_records]
+ STATE["last_otp"] = record
+ STATE["last_rowid"] = max(int(STATE.get("last_rowid") or 0), int(record.get("rowid") or 0))
+
+
+def parse_timestamp_ms(value: object) -> int:
+ if value is None:
+ return 0
+ if isinstance(value, (int, float)):
+ numeric = float(value)
+ else:
+ raw = str(value).strip()
+ if not raw:
+ return 0
+ try:
+ numeric = float(raw)
+ except ValueError:
+ try:
+ parsed = dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=dt.timezone.utc)
+ return int(parsed.timestamp() * 1000)
+ except ValueError:
+ return 0
+ if numeric <= 0:
+ return 0
+ if numeric < 100_000_000_000:
+ numeric *= 1000
+ return int(numeric)
+
+
+def select_otp_record(state: dict, after_ms: int = 0) -> Optional[dict]:
+ records = state.get("otps")
+ if not isinstance(records, list):
+ records = []
+ if after_ms > 0:
+ for record in records:
+ if isinstance(record, dict) and parse_timestamp_ms(record.get("received_at")) >= after_ms:
+ return record
+ return None
+ record = state.get("last_otp") or None
+ if isinstance(record, dict):
+ return record
+ return records[0] if records and isinstance(records[0], dict) else None
+
+
+def scan_once(db_path: Path, require_keywords: bool = True) -> None:
+ state = get_state()
+ after_rowid = int(state.get("last_rowid") or 0)
+ rows = read_recent_messages(db_path, after_rowid=after_rowid)
+ max_rowid = after_rowid
+ for row in reversed(rows):
+ max_rowid = max(max_rowid, int(row.get("rowid") or 0))
+ otp = extract_gopay_otp(row.get("text") or "", require_keywords=require_keywords)
+ if otp:
+ record = make_otp_record(row, otp)
+ append_otp(record)
+ print(f"captured OTP {otp} from message {record['message_id']} at {record['received_at']}", flush=True)
+ update_state(last_rowid=max_rowid, last_scan_at=dt.datetime.now(dt.timezone.utc).isoformat(), last_error="")
+
+
+def scan_loop(db_path: Path, interval_seconds: float, require_keywords: bool) -> None:
+ update_state(db_path=str(db_path))
+ while True:
+ try:
+ scan_once(db_path, require_keywords=require_keywords)
+ except Exception as exc:
+ update_state(last_error=str(exc), last_scan_at=dt.datetime.now(dt.timezone.utc).isoformat())
+ print(f"scan error: {exc}", file=sys.stderr, flush=True)
+ time.sleep(max(0.5, float(interval_seconds or 2)))
+
+
+def write_json(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None:
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ handler.send_response(status)
+ handler.send_header("Content-Type", "application/json; charset=utf-8")
+ handler.send_header("Access-Control-Allow-Origin", "*")
+ handler.send_header("Cache-Control", "no-store")
+ handler.send_header("Content-Length", str(len(body)))
+ handler.end_headers()
+ handler.wfile.write(body)
+
+
+class HelperHandler(BaseHTTPRequestHandler):
+ server_version = "GpcSmsHelper/1.0"
+
+ def log_message(self, fmt: str, *args: object) -> None:
+ print(f"{self.address_string()} - {fmt % args}", flush=True)
+
+ def do_GET(self) -> None: # noqa: N802
+ parsed = urlparse(self.path)
+ if parsed.path == "/health":
+ state = get_state()
+ write_json(self, 200, {
+ "ok": True,
+ "status": "ok",
+ "has_otp": bool(state.get("last_otp")),
+ "last_scan_at": state.get("last_scan_at"),
+ "last_error": state.get("last_error"),
+ })
+ return
+ if parsed.path in ("/otp", "/latest-otp"):
+ query = parse_qs(parsed.query)
+ consume = str(query.get("consume", ["0"])[0]).strip().lower() in {"1", "true", "yes"}
+ after_ms = parse_timestamp_ms(query.get("after_ms", query.get("after", ["0"]))[0])
+ state = get_state()
+ record = select_otp_record(state, after_ms=after_ms)
+ if not record:
+ write_json(self, 200, {"ok": True, "otp": "", "code": "", "status": "waiting", "message": "未查询到验证码"})
+ return
+ payload = {"ok": True, "status": "found", **record}
+ if consume:
+ update_state(last_otp=None)
+ write_json(self, 200, payload)
+ return
+ write_json(self, 404, {"ok": False, "error": "not_found"})
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Run a local macOS Messages HTTP helper for GPC SMS OTP.")
+ parser.add_argument("--host", default=HOST, help="Bind host, default 127.0.0.1")
+ parser.add_argument("--port", type=int, default=PORT, help="Bind port, default 18767")
+ parser.add_argument("--db", default=DEFAULT_DB, help="Path to macOS Messages chat.db")
+ parser.add_argument("--interval", type=float, default=2.0, help="Message scan interval in seconds")
+ parser.add_argument("--no-keywords", action="store_true", help="Accept any numeric OTP without GPC/OpenAI keywords")
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+ try:
+ require_macos()
+ db_path = Path(os.path.expanduser(args.db)).resolve()
+ scanner = threading.Thread(
+ target=scan_loop,
+ args=(db_path, args.interval, not args.no_keywords),
+ daemon=True,
+ )
+ scanner.start()
+ server = ThreadingHTTPServer((args.host, int(args.port)), HelperHandler)
+ print(f"GPC SMS Helper listening on http://{args.host}:{int(args.port)}", flush=True)
+ print("请确认 iPhone 短信已转发到本机 Messages。", flush=True)
+ server.serve_forever()
+ return 0
+ except KeyboardInterrupt:
+ return 0
+ except Exception as exc:
+ print(str(exc), file=sys.stderr, flush=True)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html
index a80d989..1584dd9 100644
--- a/sidepanel/sidepanel.html
+++ b/sidepanel/sidepanel.html
@@ -315,6 +315,28 @@
+
+ GPC OTP
+
+
+
+ 本地短信
+
+ 从本机 macOS Messages helper 读取 SMS OTP
+
+
+ 短信接口
+
+
GPC PIN
diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js
index dcb921d..62525a5 100644
--- a/sidepanel/sidepanel.js
+++ b/sidepanel/sidepanel.js
@@ -194,6 +194,12 @@ const rowGpcHelperCountryCode = document.getElementById('row-gpc-helper-country-
const selectGpcHelperCountryCode = document.getElementById('select-gpc-helper-country-code');
const rowGpcHelperPhone = document.getElementById('row-gpc-helper-phone');
const inputGpcHelperPhone = document.getElementById('input-gpc-helper-phone');
+const rowGpcHelperOtpChannel = document.getElementById('row-gpc-helper-otp-channel');
+const selectGpcHelperOtpChannel = document.getElementById('select-gpc-helper-otp-channel');
+const rowGpcHelperLocalSmsEnabled = document.getElementById('row-gpc-helper-local-sms-enabled');
+const inputGpcHelperLocalSmsEnabled = document.getElementById('input-gpc-helper-local-sms-enabled');
+const rowGpcHelperLocalSmsUrl = document.getElementById('row-gpc-helper-local-sms-url');
+const inputGpcHelperLocalSmsUrl = document.getElementById('input-gpc-helper-local-sms-url');
const rowGpcHelperPin = document.getElementById('row-gpc-helper-pin');
const inputGpcHelperPin = document.getElementById('input-gpc-helper-pin');
const btnToggleGpcHelperPin = document.getElementById('btn-toggle-gpc-helper-pin');
@@ -2032,6 +2038,34 @@ function getSelectedPlusPaymentMethod(state = latestState) {
return normalizePlusPaymentMethod(state?.plusPaymentMethod || currentPlusPaymentMethod || defaultMethod);
}
+function normalizeGpcOtpChannelValue(value = '') {
+ const rootScope = typeof window !== 'undefined' ? window : globalThis;
+ if (rootScope.GoPayUtils?.normalizeGpcOtpChannel) {
+ return rootScope.GoPayUtils.normalizeGpcOtpChannel(value);
+ }
+ return String(value || '').trim().toLowerCase() === 'sms' ? 'sms' : 'whatsapp';
+}
+
+function normalizeGpcLocalSmsHelperBaseUrlValue(value = '') {
+ const fallback = 'http://127.0.0.1:18767';
+ const rawValue = String(value || fallback).trim();
+ try {
+ const parsed = new URL(rawValue);
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
+ return fallback;
+ }
+ const endpointPath = parsed.pathname.replace(/\/+$/g, '') || '/';
+ if (['/otp', '/latest-otp', '/health'].includes(endpointPath)) {
+ parsed.pathname = '';
+ parsed.search = '';
+ parsed.hash = '';
+ }
+ return parsed.toString().replace(/\/$/, '');
+ } catch {
+ return fallback;
+ }
+}
+
function hasOwnStateValue(source, key) {
return Object.prototype.hasOwnProperty.call(source, key);
}
@@ -2738,6 +2772,36 @@ function collectSettingsPayload() {
.map((line) => line.trim())
.filter(Boolean)
.join('\n'));
+ const normalizeGpcOtpChannelSafe = typeof normalizeGpcOtpChannelValue === 'function'
+ ? normalizeGpcOtpChannelValue
+ : ((value = '') => {
+ const rootScope = typeof window !== 'undefined' ? window : globalThis;
+ if (rootScope.GoPayUtils?.normalizeGpcOtpChannel) {
+ return rootScope.GoPayUtils.normalizeGpcOtpChannel(value);
+ }
+ return String(value || '').trim().toLowerCase() === 'sms' ? 'sms' : 'whatsapp';
+ });
+ const normalizeGpcLocalSmsHelperBaseUrlSafe = typeof normalizeGpcLocalSmsHelperBaseUrlValue === 'function'
+ ? normalizeGpcLocalSmsHelperBaseUrlValue
+ : ((value = '') => {
+ const fallback = 'http://127.0.0.1:18767';
+ const rawValue = String(value || fallback).trim();
+ try {
+ const parsed = new URL(rawValue);
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
+ return fallback;
+ }
+ const endpointPath = parsed.pathname.replace(/\/+$/g, '') || '/';
+ if (['/otp', '/latest-otp', '/health'].includes(endpointPath)) {
+ parsed.pathname = '';
+ parsed.search = '';
+ parsed.hash = '';
+ }
+ return parsed.toString().replace(/\/$/, '');
+ } catch {
+ return fallback;
+ }
+ });
const getSelectedIpProxyEnabledSafe = typeof getSelectedIpProxyEnabled === 'function'
? getSelectedIpProxyEnabled
: (() => false);
@@ -3078,6 +3142,16 @@ function collectSettingsPayload() {
: (String(latestState?.signupMethod || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email'))
);
const plusPaymentMethod = getSelectedPlusPaymentMethod();
+ const selectedGpcOtpChannel = normalizeGpcOtpChannelSafe(
+ typeof selectGpcHelperOtpChannel !== 'undefined' && selectGpcHelperOtpChannel
+ ? selectGpcHelperOtpChannel.value
+ : (latestState?.gopayHelperOtpChannel || 'whatsapp')
+ );
+ const selectedGpcLocalSmsHelperEnabled = selectedGpcOtpChannel === 'sms' && Boolean(
+ typeof inputGpcHelperLocalSmsEnabled !== 'undefined' && inputGpcHelperLocalSmsEnabled
+ ? inputGpcHelperLocalSmsEnabled.checked
+ : latestState?.gopayHelperLocalSmsHelperEnabled
+ );
const selectedSub2ApiGroupName = String(inputSub2ApiGroup.value || '').trim();
const sub2apiGroupNames = [];
const seenSub2ApiGroupNames = new Set();
@@ -3193,6 +3267,13 @@ function collectSettingsPayload() {
: (typeof inputGpcHelperPin !== 'undefined' && inputGpcHelperPin
? String(inputGpcHelperPin.value || '')
: String(latestState?.gopayHelperPin || '')),
+ gopayHelperOtpChannel: selectedGpcOtpChannel,
+ gopayHelperLocalSmsHelperEnabled: selectedGpcLocalSmsHelperEnabled,
+ gopayHelperLocalSmsHelperUrl: normalizeGpcLocalSmsHelperBaseUrlSafe(
+ typeof inputGpcHelperLocalSmsUrl !== 'undefined' && inputGpcHelperLocalSmsUrl
+ ? inputGpcHelperLocalSmsUrl.value
+ : (latestState?.gopayHelperLocalSmsHelperUrl || '')
+ ),
...(contributionModeEnabled ? {} : {
customPassword: inputPassword.value,
}),
@@ -6919,6 +7000,22 @@ function updatePlusModeUI() {
? Boolean(inputPlusModeEnabled.checked)
: false;
const method = enabled ? getSelectedPlusPaymentMethod() : defaultMethod;
+ const gpcOtpChannel = normalizeGpcOtpChannelValue(
+ typeof selectGpcHelperOtpChannel !== 'undefined' && selectGpcHelperOtpChannel
+ ? selectGpcHelperOtpChannel.value
+ : (latestState?.gopayHelperOtpChannel || 'whatsapp')
+ );
+ const localSmsEnabled = Boolean(
+ typeof inputGpcHelperLocalSmsEnabled !== 'undefined' && inputGpcHelperLocalSmsEnabled
+ ? inputGpcHelperLocalSmsEnabled.checked
+ : latestState?.gopayHelperLocalSmsHelperEnabled
+ );
+ const selectedMethod = typeof selectPlusPaymentMethod !== 'undefined' && selectPlusPaymentMethod?.value
+ ? normalizePlusPaymentMethod(selectPlusPaymentMethod.value)
+ : method;
+ const gpcRowsVisible = enabled && selectedMethod === gpcValue;
+ const localSmsControlsVisible = gpcRowsVisible && gpcOtpChannel === 'sms';
+ const effectiveLocalSmsEnabled = gpcOtpChannel === 'sms' && localSmsEnabled;
if (typeof selectPlusPaymentMethod !== 'undefined' && selectPlusPaymentMethod) {
selectPlusPaymentMethod.value = method;
if (selectPlusPaymentMethod.style) {
@@ -6946,27 +7043,34 @@ function updatePlusModeUI() {
if (!row) {
return;
}
- const selectedMethod = typeof selectPlusPaymentMethod !== 'undefined' && selectPlusPaymentMethod?.value
- ? normalizePlusPaymentMethod(selectPlusPaymentMethod.value)
- : method;
row.style.display = enabled && selectedMethod === paypalValue ? '' : 'none';
});
[
typeof rowGpcHelperCardKey !== 'undefined' ? rowGpcHelperCardKey : null,
typeof rowGpcHelperCountryCode !== 'undefined' ? rowGpcHelperCountryCode : null,
typeof rowGpcHelperPhone !== 'undefined' ? rowGpcHelperPhone : null,
+ typeof rowGpcHelperOtpChannel !== 'undefined' ? rowGpcHelperOtpChannel : null,
typeof rowGpcHelperPin !== 'undefined' ? rowGpcHelperPin : null,
].forEach((row) => {
if (!row) {
return;
}
- const selectedMethod = typeof selectPlusPaymentMethod !== 'undefined' && selectPlusPaymentMethod?.value
- ? normalizePlusPaymentMethod(selectPlusPaymentMethod.value)
- : method;
- row.style.display = enabled && selectedMethod === gpcValue ? '' : 'none';
+ row.style.display = gpcRowsVisible ? '' : 'none';
});
+ if (typeof selectGpcHelperOtpChannel !== 'undefined' && selectGpcHelperOtpChannel) {
+ selectGpcHelperOtpChannel.value = gpcOtpChannel;
+ }
+ if (typeof inputGpcHelperLocalSmsEnabled !== 'undefined' && inputGpcHelperLocalSmsEnabled) {
+ inputGpcHelperLocalSmsEnabled.checked = effectiveLocalSmsEnabled;
+ }
+ if (typeof rowGpcHelperLocalSmsEnabled !== 'undefined' && rowGpcHelperLocalSmsEnabled) {
+ rowGpcHelperLocalSmsEnabled.style.display = localSmsControlsVisible ? '' : 'none';
+ }
+ if (typeof rowGpcHelperLocalSmsUrl !== 'undefined' && rowGpcHelperLocalSmsUrl) {
+ rowGpcHelperLocalSmsUrl.style.display = localSmsControlsVisible && effectiveLocalSmsEnabled ? '' : 'none';
+ }
if (typeof btnGpcCardKeyPurchase !== 'undefined' && btnGpcCardKeyPurchase) {
- btnGpcCardKeyPurchase.style.display = enabled && method === gpcValue ? '' : 'none';
+ btnGpcCardKeyPurchase.style.display = gpcRowsVisible ? '' : 'none';
}
[
typeof rowGoPayCountryCode !== 'undefined' ? rowGoPayCountryCode : null,
@@ -6977,9 +7081,6 @@ function updatePlusModeUI() {
if (!row) {
return;
}
- const selectedMethod = typeof selectPlusPaymentMethod !== 'undefined' && selectPlusPaymentMethod?.value
- ? normalizePlusPaymentMethod(selectPlusPaymentMethod.value)
- : method;
row.style.display = enabled && selectedMethod === gopayValue ? '' : 'none';
});
}
@@ -7695,6 +7796,15 @@ function applySettingsState(state) {
if (typeof inputGpcHelperPhone !== 'undefined' && inputGpcHelperPhone) {
inputGpcHelperPhone.value = state?.gopayHelperPhoneNumber || '';
}
+ if (typeof selectGpcHelperOtpChannel !== 'undefined' && selectGpcHelperOtpChannel) {
+ selectGpcHelperOtpChannel.value = normalizeGpcOtpChannelValue(state?.gopayHelperOtpChannel || 'whatsapp');
+ }
+ if (typeof inputGpcHelperLocalSmsEnabled !== 'undefined' && inputGpcHelperLocalSmsEnabled) {
+ inputGpcHelperLocalSmsEnabled.checked = Boolean(state?.gopayHelperLocalSmsHelperEnabled);
+ }
+ if (typeof inputGpcHelperLocalSmsUrl !== 'undefined' && inputGpcHelperLocalSmsUrl) {
+ inputGpcHelperLocalSmsUrl.value = normalizeGpcLocalSmsHelperBaseUrlValue(state?.gopayHelperLocalSmsHelperUrl || '');
+ }
if (typeof inputGpcHelperPin !== 'undefined' && inputGpcHelperPin) {
inputGpcHelperPin.value = state?.gopayHelperPin || '';
}
@@ -11105,6 +11215,9 @@ selectPlusPaymentMethod?.addEventListener('change', () => {
inputGpcHelperCardKey,
selectGpcHelperCountryCode,
inputGpcHelperPhone,
+ selectGpcHelperOtpChannel,
+ inputGpcHelperLocalSmsEnabled,
+ inputGpcHelperLocalSmsUrl,
inputGpcHelperPin,
selectGoPayCountryCode,
inputGoPayPhone,
@@ -11115,6 +11228,16 @@ selectPlusPaymentMethod?.addEventListener('change', () => {
markSettingsDirty(true);
scheduleSettingsAutoSave();
});
+ input?.addEventListener('change', () => {
+ if (input === inputGpcHelperLocalSmsEnabled && input.checked && selectGpcHelperOtpChannel) {
+ selectGpcHelperOtpChannel.value = 'sms';
+ }
+ if (input === selectGpcHelperOtpChannel || input === inputGpcHelperLocalSmsEnabled) {
+ updatePlusModeUI();
+ }
+ markSettingsDirty(true);
+ saveSettings({ silent: true }).catch(() => { });
+ });
input?.addEventListener('blur', () => {
saveSettings({ silent: true }).catch(() => { });
});
@@ -12937,7 +13060,30 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.payload.plusPaymentMethod !== undefined && selectPlusPaymentMethod) {
selectPlusPaymentMethod.value = normalizePlusPaymentMethod(message.payload.plusPaymentMethod);
}
- if (message.payload.plusModeEnabled !== undefined || message.payload.plusPaymentMethod !== undefined) {
+ if (message.payload.gopayHelperOtpChannel !== undefined && selectGpcHelperOtpChannel) {
+ selectGpcHelperOtpChannel.value = normalizeGpcOtpChannelValue(message.payload.gopayHelperOtpChannel);
+ }
+ if (message.payload.gopayHelperLocalSmsHelperEnabled !== undefined && inputGpcHelperLocalSmsEnabled) {
+ inputGpcHelperLocalSmsEnabled.checked = Boolean(message.payload.gopayHelperLocalSmsHelperEnabled);
+ }
+ if (message.payload.gopayHelperLocalSmsHelperUrl !== undefined && inputGpcHelperLocalSmsUrl) {
+ inputGpcHelperLocalSmsUrl.value = normalizeGpcLocalSmsHelperBaseUrlValue(message.payload.gopayHelperLocalSmsHelperUrl);
+ }
+ if (message.payload.gopayHelperBalance !== undefined || message.payload.gopayHelperBalanceError !== undefined) {
+ if (typeof displayGpcHelperBalance !== 'undefined' && displayGpcHelperBalance) {
+ const balanceText = String(message.payload.gopayHelperBalance ?? latestState?.gopayHelperBalance ?? '').trim();
+ const balanceError = String(message.payload.gopayHelperBalanceError ?? latestState?.gopayHelperBalanceError ?? '').trim();
+ displayGpcHelperBalance.textContent = balanceError
+ ? `余额查询失败:${balanceError}`
+ : (balanceText || '余额已更新');
+ }
+ }
+ if (
+ message.payload.plusModeEnabled !== undefined
+ || message.payload.plusPaymentMethod !== undefined
+ || message.payload.gopayHelperOtpChannel !== undefined
+ || message.payload.gopayHelperLocalSmsHelperEnabled !== undefined
+ ) {
syncStepDefinitionsForMode(
Boolean(latestState?.plusModeEnabled),
latestState?.plusPaymentMethod,
diff --git a/tests/background-account-history-settings.test.js b/tests/background-account-history-settings.test.js
index b72c0c6..92a8d27 100644
--- a/tests/background-account-history-settings.test.js
+++ b/tests/background-account-history-settings.test.js
@@ -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);
diff --git a/tests/gopay-utils.test.js b/tests/gopay-utils.test.js
index f5967a7..476c291 100644
--- a/tests/gopay-utils.test.js
+++ b/tests/gopay-utils.test.js
@@ -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', () => {
diff --git a/tests/gpc-sms-helper-script.test.js b/tests/gpc-sms-helper-script.test.js
new file mode 100644
index 0000000..79574d4
--- /dev/null
+++ b/tests/gpc-sms-helper-script.test.js
@@ -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,
+ });
+});
diff --git a/tests/plus-checkout-billing-tab-resolution.test.js b/tests/plus-checkout-billing-tab-resolution.test.js
index ade483a..43005ea 100644
--- a/tests/plus-checkout-billing-tab-resolution.test.js
+++ b/tests/plus-checkout-billing-tab-resolution.test.js
@@ -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 = {
diff --git a/tests/plus-checkout-create-wait.test.js b/tests/plus-checkout-create-wait.test.js
index 30c3c99..efc6605 100644
--- a/tests/plus-checkout-create-wait.test.js
+++ b/tests/plus-checkout-create-wait.test.js
@@ -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 = [];
diff --git a/tests/sidepanel-plus-payment-method.test.js b/tests/sidepanel-plus-payment-method.test.js
index ca141fc..b05f552 100644
--- a/tests/sidepanel-plus-payment-method.test.js
+++ b/tests/sidepanel-plus-payment-method.test.js
@@ -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');
diff --git a/tests/step-definitions-module.test.js b/tests/step-definitions-module.test.js
index 974b6c6..deaaa74 100644
--- a/tests/step-definitions-module.test.js
+++ b/tests/step-definitions-module.test.js
@@ -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"/);
});
diff --git a/项目完整链路说明.md b/项目完整链路说明.md
index e8243f6..5853f6e 100644
--- a/项目完整链路说明.md
+++ b/项目完整链路说明.md
@@ -45,7 +45,7 @@
- 查询 GitHub Releases 并展示更新卡片;当前更新服务会区分 `Ultra`、历史 `Pro` 与 legacy `v` 三个版本族,排序时固定以 `Ultra` 为最高正式系列,同时会在读取缓存后重新排序,避免历史 `Pro` 或 `v` 版本误显示为比 `Ultra` 更新
- 展示一个单独的“接码”开关、注册方式 `signupMethod` 与“接码平台”下拉;接码平台当前支持 HeroSMS / 5sim / NexSMS。普通模式下开启接码后可把注册方式切到手机号注册,并在 OAuth 登录链路命中手机号登录验证码页时继续复用同一手机号续跑短信验证
- 侧栏在接码卡内提供一个独立运行态“注册手机号”输入框,位于接码订单运行状态下方;第 2 步自动拿到号码后立即回填,用户手动接管手机号注册时也可以直接改写这一个运行态槽位。这个输入框表达账号身份,不等同于接码订单;后续自动拉短信仍依赖 `signupPhoneActivation / signupPhoneCompletedActivation`
-- 展示 `Plus 模式` 开关与 Plus 支付方式配置;支付方式支持 PayPal / GoPay,PayPal 展示账号池下拉与添加按钮,GoPay 展示手机号和 PIN;步骤列表切换为 Plus 模式 13 步定义,普通模式的 Cookie 清理步骤不再显示或执行,登录验证码步骤会移动到 Plus 可见第 11 步
+- 展示 `Plus 模式` 开关与 Plus 支付方式配置;支付方式支持 PayPal / GoPay / GPC,PayPal 展示账号池下拉与添加按钮,GoPay 展示手机号和 PIN,GPC 展示卡密、专用手机号、OTP 渠道、本地 macOS SMS helper 开关与 URL、PIN;步骤列表切换为 Plus 模式 13 步定义,普通模式的 Cookie 清理步骤不再显示或执行,登录验证码步骤会移动到 Plus 可见第 11 步
- 为 Hotmail / 2925 账号池复用同一套“添加账号 / 取消添加 / 批量导入 / 收起列表”表单交互;共享的显隐控制放在 `sidepanel/account-pool-ui.js`,各自 manager 只保留 provider 相关字段校验与业务操作
### 2.2 Background Service Worker
@@ -182,7 +182,7 @@
- Codex2API 配置
- IP 代理持久配置:`ipProxyEnabled`、服务商、模式、API 地址、服务商配置快照、账号列表、固定 Host / Port / Protocol / Username / Password、地区参数、session 与自动切换阈值
- Plus 模式开关 `plusModeEnabled`
-- Plus 支付方式 `plusPaymentMethod`,GoPay 配置 `gopayPhone / gopayPin`
+- Plus 支付方式 `plusPaymentMethod`,GoPay 配置 `gopayPhone / gopayPin`,GPC helper 配置 `gopayHelperCardKey / gopayHelperPhoneNumber / gopayHelperOtpChannel / gopayHelperLocalSmsHelperEnabled / gopayHelperLocalSmsHelperUrl / gopayHelperPin`
- PayPal 账号池配置 `paypalAccounts / currentPayPalAccountId`,以及供后台步骤兼容读取的 `paypalEmail / paypalPassword`
- 邮箱 provider 配置
- Hotmail 账号池
@@ -546,8 +546,8 @@ Plus 模式通过 `plusModeEnabled` 开启,目标是在普通注册资料完
Plus 模式可见步骤:
1. 第 1~5 步:沿用普通注册入口、邮箱、密码、注册验证码、资料填写链路。
-2. 第 6 步 `创建 Plus Checkout`:打开已登录 ChatGPT 页面,通过 `/api/auth/session` 读取 accessToken,再请求 `https://chatgpt.com/backend-api/payments/checkout` 创建 `chatgptplusplan` 的 checkout session。PayPal 使用 `DE / EUR` 与 `https://chatgpt.com/checkout/openai_ie/{checkout_session_id}`;GoPay 使用 `ID / IDR` 与 `https://chatgpt.com/checkout/openai_llc/{checkout_session_id}`。
-3. 第 7 步 `填写账单并提交订阅`:按 `plusPaymentMethod` 选择 PayPal 或 GoPay,生成账单全名,从 `data/address-sources.js` 读取地址 seed query,触发 checkout 内置 Google 地址推荐,选择推荐项并校验地址第 1 行、城市、州/省、邮编等结构化字段,再点击“订阅”。GoPay 模式固定使用印尼地址,不根据 IP 国家推断。运行时会按 Stripe iframe 拆分执行:付款方式在 `elements-inner-payment` frame,账单地址在 `elements-inner-address` frame,Google 推荐在 `elements-inner-autocompl` frame 时单独点击推荐项。
+2. 第 6 步 `创建 Plus Checkout`:打开已登录 ChatGPT 页面,通过 `/api/auth/session` 读取 accessToken,再请求 `https://chatgpt.com/backend-api/payments/checkout` 创建 `chatgptplusplan` 的 checkout session。PayPal 使用 `DE / EUR` 与 `https://chatgpt.com/checkout/openai_ie/{checkout_session_id}`;GoPay 使用 `ID / IDR` 与 `https://chatgpt.com/checkout/openai_llc/{checkout_session_id}`;GPC helper 模式改为把 accessToken、卡密、手机号、`otp_channel` 等提交给 helper API 创建 GPC 订单。
+3. 第 7 步 `填写账单并提交订阅`:按 `plusPaymentMethod` 选择 PayPal、GoPay 或 GPC。PayPal / GoPay 仍走 checkout 页面与 Stripe iframe 自动化;GPC helper 模式不再操作 checkout iframe,而是先提交 OTP,再提交 PIN。若侧栏启用本地 macOS SMS helper,后台会先轮询 `gopayHelperLocalSmsHelperUrl` 的 `/otp` 接口读取 SMS OTP,读取失败再回退到手动 OTP 输入弹窗。
4. 第 8 步按当前支付方式显示为 `PayPal 登录与授权` 或 `GoPay 手机验证与授权`,底层 step key 仍为 `paypal-approve`。PayPal 模式后台优先读取侧边栏当前选中的 PayPal 账号;GoPay 模式读取侧边栏的国家区号、手机号、可选验证码和 PIN,先在 GoPay 页面填写手机号;验证码优先用侧边栏已填值,否则弹出插件输入框让用户手动填写,提交后继续填写 PIN。
5. 第 9 步 `订阅回跳确认`:等待 PayPal / GoPay 授权后回跳到 ChatGPT / OpenAI 页面,页面加载完成后固定等待 1 秒。
6. 第 10 步:复用原 Step 7 OAuth 登录执行器,但状态和日志按 Plus 可见第 10 步记录。
diff --git a/项目文件结构说明.md b/项目文件结构说明.md
index 6903257..5b4e667 100644
--- a/项目文件结构说明.md
+++ b/项目文件结构说明.md
@@ -125,6 +125,7 @@
## `scripts/`
+- `scripts/gpc_sms_helper_macos.py`:GPC Plus helper 的 macOS 本地短信验证码 helper,读取本机 Messages 数据库中的 GoPay/OpenAI 短信 OTP,并通过 `http://127.0.0.1:18767/otp` 提供给扩展自动轮询。
- `scripts/hotmail_helper.py`:本地 helper 服务,负责通过本地接口协助 Hotmail 获取邮件和验证码,并提供账号记录 JSON 快照同步接口;旧的文本追加接口仍保留作兼容。
## `sidepanel/`
@@ -149,7 +150,7 @@
receive 模式把账号池开关一起隐藏;当前在 `邮箱生成` 区域新增 `自定义邮箱池` 选项和多行邮箱池输入框,并在 `邮箱服务 = 自定义邮箱` 时
额外显示 `自定义号池` 文本框;当邮箱服务为 iCloud 时,额外提供目标邮箱类型与转发邮箱 provider 配置,用于选择直接从 iCloud 收件箱收
码或从 QQ / 网易 / Gmail 转发目标邮箱收码;来源下拉框当前支持 `CPA / SUB2API / Codex2API`,其中 Codex2API 额外提供后台地址和管理密
- 钥配置行;设置卡片新增 `IP代理` 开关与代理配置折叠区,支持 711Proxy 账号密码模式、代理状态卡与出口检测按钮;接码卡内把注册方式、接码配置、接码订单运行态与“注册手机号”身份运行态分层展示,“注册手机号”位于订单运行状态下方,不嵌入当前分配/验证码网格;设置卡片新增 `Plus 模式` 开关与 PayPal 账号下拉框,右侧使用公共表单弹窗添加账号;Hotmail / 2925 两个账号池当前都使用统一的头部“添加账号/取消添加”按钮和共享表单容器;设置卡片还新增“授权总超时”开关,用于控制 Step 7 后链 5 分钟总预算。
+ 钥配置行;设置卡片新增 `IP代理` 开关与代理配置折叠区,支持 711Proxy 账号密码模式、代理状态卡与出口检测按钮;接码卡内把注册方式、接码配置、接码订单运行态与“注册手机号”身份运行态分层展示,“注册手机号”位于订单运行状态下方,不嵌入当前分配/验证码网格;设置卡片新增 `Plus 模式` 开关与 PayPal 账号下拉框,右侧使用公共表单弹窗添加账号;GPC Plus 配置额外提供 OTP 渠道选择、本地 macOS SMS helper 开关和 helper URL;Hotmail / 2925 两个账号池当前都使用统一的头部“添加账号/取消添加”按钮和共享表单容器;设置卡片还新增“授权总超时”开关,用于控制 Step 7 后链 5 分钟总预算。
- `sidepanel/paypal-manager.js`:侧边栏 PayPal 账号管理器,负责 Plus 模式下的账号下拉框渲染、添加账号弹窗、保存账号与切换当前账号。
- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、共享验证码自动重发次数配置与广播接收,并装
配 Hotmail / 2925 / iCloud / LuckMail / 贡献模式 / 账号记录面板 / 贡献内容更新服务;当前贡献模式的“开始贡献”会直接复用主自动流程启
@@ -213,7 +214,8 @@
- `tests/phone-verification-flow.test.js`:测试手机号验证共享流程对 HeroSMS 的取号、复用、重发、换号与 Step 7 重开错误分支。
- `tests/paypal-approve-detection.test.js`:测试 Plus 第 8 步后台执行器对 PayPal 标签页发现、分离式账号/密码页识别、联合登录页识别,以及登录后直接离开 PayPal 页的分支判断。
- `tests/paypal-flow-content.test.js`:测试 PayPal 内容脚本对可见邮箱/密码输入框的识别,并覆盖邮箱页即使已预填相同账号也会先清空再重填后继续下一步。
-- `tests/plus-checkout-billing-tab-resolution.test.js`:测试 Plus 第 7 步可直接接管当前 checkout 标签页,并把 PayPal、账单地址、Google 地址推荐和订阅按钮操作路由到对应 Stripe iframe。
+- `tests/gpc-sms-helper-script.test.js`:测试 GPC macOS 本地短信 helper 在非 macOS 环境下会提示平台与 iPhone 短信转发要求。
+- `tests/plus-checkout-billing-tab-resolution.test.js`:测试 Plus 第 7 步可直接接管当前 checkout 标签页,并把 PayPal、账单地址、Google 地址推荐和订阅按钮操作路由到对应 Stripe iframe;当前也覆盖 GPC helper 的本地 SMS OTP 自动读取分支。
- `tests/cloudflare-temp-email-provider.test.js`:测试 Cloudflare Temp Email provider 的轮询与目标邮箱选择逻辑。
- `tests/cloudflare-temp-email-utils.test.js`:测试 Cloudflare Temp Email 工具层的 URL、域名、邮件解析逻辑。
- `tests/content-utils.test.js`:测试内容脚本公共工具层对 `mail.126.com` 等网页邮箱来源的识别逻辑。