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:
QLHazyCoder
2026-05-06 01:39:11 +08:00
parent 7b9f4a12ec
commit aa57e0f645
16 changed files with 980 additions and 27 deletions
+63
View File
@@ -609,6 +609,11 @@ const PERSISTED_SETTING_DEFAULTS = {
gopayHelperPhoneNumber: '', gopayHelperPhoneNumber: '',
gopayHelperCountryCode: '+86', gopayHelperCountryCode: '+86',
gopayHelperPin: '', gopayHelperPin: '',
gopayHelperOtpChannel: 'whatsapp',
gopayHelperLocalSmsHelperEnabled: false,
gopayHelperLocalSmsHelperUrl: 'http://127.0.0.1:18767',
gopayHelperLocalSmsTimeoutSeconds: 90,
gopayHelperLocalSmsPollIntervalSeconds: 2,
gopayHelperReferenceId: '', gopayHelperReferenceId: '',
gopayHelperGoPayGuid: '', gopayHelperGoPayGuid: '',
gopayHelperRedirectUrl: '', gopayHelperRedirectUrl: '',
@@ -774,6 +779,7 @@ const DEFAULT_STATE = {
gopayHelperFlowId: '', gopayHelperFlowId: '',
gopayHelperChallengeId: '', gopayHelperChallengeId: '',
gopayHelperStartPayload: null, gopayHelperStartPayload: null,
gopayHelperOrderCreatedAt: 0,
gopayHelperPinPayload: null, gopayHelperPinPayload: null,
gopayHelperResolvedOtp: '', gopayHelperResolvedOtp: '',
gopayHelperOtpRequestId: '', 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 = '') { function normalizeHeroSmsMaxPrice(value = '') {
const rawValue = String(value ?? '').trim(); const rawValue = String(value ?? '').trim();
if (!rawValue) { if (!rawValue) {
@@ -2213,6 +2251,29 @@ function normalizePersistentSettingValue(key, value) {
return self.GoPayUtils?.normalizeGoPayCountryCode return self.GoPayUtils?.normalizeGoPayCountryCode
? self.GoPayUtils.normalizeGoPayCountryCode(value) ? self.GoPayUtils.normalizeGoPayCountryCode(value)
: String(value || '+86').trim(); : 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': case 'gopayHelperApiUrl':
{ {
const defaultGpcHelperApiUrl = PERSISTED_SETTING_DEFAULTS.gopayHelperApiUrl const defaultGpcHelperApiUrl = PERSISTED_SETTING_DEFAULTS.gopayHelperApiUrl
@@ -2238,6 +2299,7 @@ function normalizePersistentSettingValue(key, value) {
return Math.max(0, Number(value) || 0); return Math.max(0, Number(value) || 0);
case 'autoRunSkipFailures': case 'autoRunSkipFailures':
case 'oauthFlowTimeoutEnabled': case 'oauthFlowTimeoutEnabled':
case 'gopayHelperLocalSmsHelperEnabled':
case 'autoRunDelayEnabled': case 'autoRunDelayEnabled':
case 'phoneVerificationEnabled': case 'phoneVerificationEnabled':
case 'plusModeEnabled': case 'plusModeEnabled':
@@ -7349,6 +7411,7 @@ function getDownstreamStateResets(step, state = {}) {
gopayHelperFlowId: '', gopayHelperFlowId: '',
gopayHelperChallengeId: '', gopayHelperChallengeId: '',
gopayHelperStartPayload: null, gopayHelperStartPayload: null,
gopayHelperOrderCreatedAt: 0,
gopayHelperPinPayload: null, gopayHelperPinPayload: null,
gopayHelperResolvedOtp: '', gopayHelperResolvedOtp: '',
gopayHelperOtpRequestId: '', gopayHelperOtpRequestId: '',
+22 -1
View File
@@ -11,7 +11,7 @@
function createPlusCheckoutCreateExecutor(deps = {}) { function createPlusCheckoutCreateExecutor(deps = {}) {
const { const {
addLog, addLog: rawAddLog = async () => {},
chrome, chrome,
completeStepFromBackground, completeStepFromBackground,
ensureContentScriptReadyOnTabUntilStopped, ensureContentScriptReadyOnTabUntilStopped,
@@ -25,6 +25,14 @@
throwIfStopped = () => {}, throwIfStopped = () => {},
} = deps; } = 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 = '') { function normalizePlusPaymentMethod(value = '') {
const rootScope = typeof self !== 'undefined' ? self : globalThis; const rootScope = typeof self !== 'undefined' ? self : globalThis;
if (rootScope.GoPayUtils?.normalizePlusPaymentMethod) { if (rootScope.GoPayUtils?.normalizePlusPaymentMethod) {
@@ -79,6 +87,14 @@
return cleaned; 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 = {}) { function resolveGpcHelperCardKey(state = {}) {
const cardKey = String(state?.gopayHelperCardKey || state?.gpcCardKey || state?.cardKey || '').trim(); const cardKey = String(state?.gopayHelperCardKey || state?.gpcCardKey || state?.cardKey || '').trim();
if (!cardKey) { if (!cardKey) {
@@ -307,9 +323,12 @@
type: 'gopay', type: 'gopay',
country_code: countryCode, country_code: countryCode,
phone_number: normalizeHelperPhoneNumber(phoneNumber, countryCode), phone_number: normalizeHelperPhoneNumber(phoneNumber, countryCode),
phone_mode: 'manual',
otp_channel: normalizeGpcOtpChannel(state?.gopayHelperOtpChannel),
}, },
}; };
const orderCreatedAt = Date.now();
const { response, data } = await fetchJsonWithTimeout(apiUrl, { const { response, data } = await fetchJsonWithTimeout(apiUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -346,6 +365,7 @@
nextAction, nextAction,
flowId, flowId,
challengeId, challengeId,
orderCreatedAt,
responsePayload: data && typeof data === 'object' && !Array.isArray(data) ? data : null, responsePayload: data && typeof data === 'object' && !Array.isArray(data) ? data : null,
country: 'ID', country: 'ID',
currency: 'IDR', currency: 'IDR',
@@ -385,6 +405,7 @@
gopayHelperFlowId: result.flowId, gopayHelperFlowId: result.flowId,
gopayHelperChallengeId: result.challengeId, gopayHelperChallengeId: result.challengeId,
gopayHelperStartPayload: result.responsePayload, gopayHelperStartPayload: result.responsePayload,
gopayHelperOrderCreatedAt: result.orderCreatedAt || Date.now(),
}); });
await addLog('步骤 6:GPC 订单已创建,准备继续下一步。', 'info'); await addLog('步骤 6:GPC 订单已创建,准备继续下一步。', 'info');
await completeStepFromBackground(6, { await completeStepFromBackground(6, {
+152 -7
View File
@@ -54,7 +54,7 @@
function createPlusCheckoutBillingExecutor(deps = {}) { function createPlusCheckoutBillingExecutor(deps = {}) {
const { const {
addLog, addLog: rawAddLog = async () => {},
broadcastDataUpdate, broadcastDataUpdate,
chrome, chrome,
completeStepFromBackground, completeStepFromBackground,
@@ -73,6 +73,14 @@
throwIfStopped = () => {}, throwIfStopped = () => {},
} = deps; } = 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 = '') { function isPlusCheckoutUrl(url = '') {
return PLUS_CHECKOUT_URL_PATTERN.test(String(url || '')); return PLUS_CHECKOUT_URL_PATTERN.test(String(url || ''));
} }
@@ -238,6 +246,129 @@
return Promise.resolve({}); 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 = '' }) { async function requestGpcOtpInput({ title = '', message = '', referenceId = '' }) {
const requestId = `otp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const requestId = `otp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const payload = { const payload = {
@@ -291,12 +422,26 @@
throw new Error('步骤 7GPC 模式缺少卡密。'); throw new Error('步骤 7GPC 模式缺少卡密。');
} }
await addLog(`步骤 7:GPC 模式开始 OTP 验证(reference_id: ${referenceId}...`, 'info'); await addLog(`步骤 7:GPC 模式开始 OTP 验证(reference_id: ${referenceId}...`, 'info');
await addLog('步骤 7:等待用户输入 OTP...', 'info'); let otp = '';
const otp = await requestGpcOtpInput({ const useLocalSmsHelper = Boolean(state?.gopayHelperLocalSmsHelperEnabled)
title: 'GPC OTP 验证', && normalizeGpcOtpChannel(state?.gopayHelperOtpChannel) === 'sms';
message: `请输入收到的 OTP 验证码(reference_id: ${referenceId}`, if (useLocalSmsHelper) {
referenceId, 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 const flowId = state?.gopayHelperFlowId
|| state?.gopayHelperStartPayload?.flow_id || state?.gopayHelperStartPayload?.flow_id
+9
View File
@@ -46,6 +46,14 @@
return String(value || '').trim().replace(/[^\d]/g, ''); 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 = '') { function normalizeGpcHelperBaseUrl(apiUrl = '') {
let normalized = String(apiUrl || DEFAULT_GPC_HELPER_API_URL).trim(); let normalized = String(apiUrl || DEFAULT_GPC_HELPER_API_URL).trim();
if (!normalized) { if (!normalized) {
@@ -213,6 +221,7 @@
normalizeGoPayPhoneForCountry, normalizeGoPayPhoneForCountry,
normalizeGoPayOtp, normalizeGoPayOtp,
normalizeGoPayPin, normalizeGoPayPin,
normalizeGpcOtpChannel,
normalizePlusPaymentMethod, normalizePlusPaymentMethod,
}; };
}); });
+313
View File
@@ -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"(?<!\d)([0-9]{6})(?!\d)"),
)
KEYWORDS = ("gojek", "gopay", "openai llc", "openai")
STATE_LOCK = threading.Lock()
STATE = {
"started_at": dt.datetime.now(dt.timezone.utc).isoformat(),
"db_path": "",
"last_scan_at": "",
"last_error": "",
"last_rowid": 0,
"last_otp": None,
"otps": [],
}
def is_macos() -> 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())
+22
View File
@@ -315,6 +315,28 @@
<input type="text" id="input-gpc-helper-phone" class="data-input" <input type="text" id="input-gpc-helper-phone" class="data-input"
placeholder="GPC 专用手机号,不读取 GoPay 手机字段" autocomplete="tel" /> placeholder="GPC 专用手机号,不读取 GoPay 手机字段" autocomplete="tel" />
</div> </div>
<div class="data-row" id="row-gpc-helper-otp-channel" style="display:none;">
<span class="data-label">GPC OTP</span>
<select id="select-gpc-helper-otp-channel" class="data-select">
<option value="whatsapp">WhatsApp</option>
<option value="sms">SMS</option>
</select>
</div>
<div class="data-row" id="row-gpc-helper-local-sms-enabled" style="display:none;">
<span class="data-label">本地短信</span>
<label class="toggle-switch">
<input type="checkbox" id="input-gpc-helper-local-sms-enabled" />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
</label>
<span class="setting-caption">从本机 macOS Messages helper 读取 SMS OTP</span>
</div>
<div class="data-row" id="row-gpc-helper-local-sms-url" style="display:none;">
<span class="data-label">短信接口</span>
<input type="text" id="input-gpc-helper-local-sms-url" class="data-input"
placeholder="http://127.0.0.1:18767" autocomplete="off" />
</div>
<div class="data-row" id="row-gpc-helper-pin" style="display:none;"> <div class="data-row" id="row-gpc-helper-pin" style="display:none;">
<span class="data-label">GPC PIN</span> <span class="data-label">GPC PIN</span>
<div class="input-with-icon"> <div class="input-with-icon">
+158 -12
View File
@@ -194,6 +194,12 @@ const rowGpcHelperCountryCode = document.getElementById('row-gpc-helper-country-
const selectGpcHelperCountryCode = document.getElementById('select-gpc-helper-country-code'); const selectGpcHelperCountryCode = document.getElementById('select-gpc-helper-country-code');
const rowGpcHelperPhone = document.getElementById('row-gpc-helper-phone'); const rowGpcHelperPhone = document.getElementById('row-gpc-helper-phone');
const inputGpcHelperPhone = document.getElementById('input-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 rowGpcHelperPin = document.getElementById('row-gpc-helper-pin');
const inputGpcHelperPin = document.getElementById('input-gpc-helper-pin'); const inputGpcHelperPin = document.getElementById('input-gpc-helper-pin');
const btnToggleGpcHelperPin = document.getElementById('btn-toggle-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); 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) { function hasOwnStateValue(source, key) {
return Object.prototype.hasOwnProperty.call(source, key); return Object.prototype.hasOwnProperty.call(source, key);
} }
@@ -2738,6 +2772,36 @@ function collectSettingsPayload() {
.map((line) => line.trim()) .map((line) => line.trim())
.filter(Boolean) .filter(Boolean)
.join('\n')); .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' const getSelectedIpProxyEnabledSafe = typeof getSelectedIpProxyEnabled === 'function'
? getSelectedIpProxyEnabled ? getSelectedIpProxyEnabled
: (() => false); : (() => false);
@@ -3078,6 +3142,16 @@ function collectSettingsPayload() {
: (String(latestState?.signupMethod || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email')) : (String(latestState?.signupMethod || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email'))
); );
const plusPaymentMethod = getSelectedPlusPaymentMethod(); 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 selectedSub2ApiGroupName = String(inputSub2ApiGroup.value || '').trim();
const sub2apiGroupNames = []; const sub2apiGroupNames = [];
const seenSub2ApiGroupNames = new Set(); const seenSub2ApiGroupNames = new Set();
@@ -3193,6 +3267,13 @@ function collectSettingsPayload() {
: (typeof inputGpcHelperPin !== 'undefined' && inputGpcHelperPin : (typeof inputGpcHelperPin !== 'undefined' && inputGpcHelperPin
? String(inputGpcHelperPin.value || '') ? String(inputGpcHelperPin.value || '')
: String(latestState?.gopayHelperPin || '')), : String(latestState?.gopayHelperPin || '')),
gopayHelperOtpChannel: selectedGpcOtpChannel,
gopayHelperLocalSmsHelperEnabled: selectedGpcLocalSmsHelperEnabled,
gopayHelperLocalSmsHelperUrl: normalizeGpcLocalSmsHelperBaseUrlSafe(
typeof inputGpcHelperLocalSmsUrl !== 'undefined' && inputGpcHelperLocalSmsUrl
? inputGpcHelperLocalSmsUrl.value
: (latestState?.gopayHelperLocalSmsHelperUrl || '')
),
...(contributionModeEnabled ? {} : { ...(contributionModeEnabled ? {} : {
customPassword: inputPassword.value, customPassword: inputPassword.value,
}), }),
@@ -6919,6 +7000,22 @@ function updatePlusModeUI() {
? Boolean(inputPlusModeEnabled.checked) ? Boolean(inputPlusModeEnabled.checked)
: false; : false;
const method = enabled ? getSelectedPlusPaymentMethod() : defaultMethod; 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) { if (typeof selectPlusPaymentMethod !== 'undefined' && selectPlusPaymentMethod) {
selectPlusPaymentMethod.value = method; selectPlusPaymentMethod.value = method;
if (selectPlusPaymentMethod.style) { if (selectPlusPaymentMethod.style) {
@@ -6946,27 +7043,34 @@ function updatePlusModeUI() {
if (!row) { if (!row) {
return; return;
} }
const selectedMethod = typeof selectPlusPaymentMethod !== 'undefined' && selectPlusPaymentMethod?.value
? normalizePlusPaymentMethod(selectPlusPaymentMethod.value)
: method;
row.style.display = enabled && selectedMethod === paypalValue ? '' : 'none'; row.style.display = enabled && selectedMethod === paypalValue ? '' : 'none';
}); });
[ [
typeof rowGpcHelperCardKey !== 'undefined' ? rowGpcHelperCardKey : null, typeof rowGpcHelperCardKey !== 'undefined' ? rowGpcHelperCardKey : null,
typeof rowGpcHelperCountryCode !== 'undefined' ? rowGpcHelperCountryCode : null, typeof rowGpcHelperCountryCode !== 'undefined' ? rowGpcHelperCountryCode : null,
typeof rowGpcHelperPhone !== 'undefined' ? rowGpcHelperPhone : null, typeof rowGpcHelperPhone !== 'undefined' ? rowGpcHelperPhone : null,
typeof rowGpcHelperOtpChannel !== 'undefined' ? rowGpcHelperOtpChannel : null,
typeof rowGpcHelperPin !== 'undefined' ? rowGpcHelperPin : null, typeof rowGpcHelperPin !== 'undefined' ? rowGpcHelperPin : null,
].forEach((row) => { ].forEach((row) => {
if (!row) { if (!row) {
return; return;
} }
const selectedMethod = typeof selectPlusPaymentMethod !== 'undefined' && selectPlusPaymentMethod?.value row.style.display = gpcRowsVisible ? '' : 'none';
? normalizePlusPaymentMethod(selectPlusPaymentMethod.value)
: method;
row.style.display = enabled && selectedMethod === gpcValue ? '' : '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) { if (typeof btnGpcCardKeyPurchase !== 'undefined' && btnGpcCardKeyPurchase) {
btnGpcCardKeyPurchase.style.display = enabled && method === gpcValue ? '' : 'none'; btnGpcCardKeyPurchase.style.display = gpcRowsVisible ? '' : 'none';
} }
[ [
typeof rowGoPayCountryCode !== 'undefined' ? rowGoPayCountryCode : null, typeof rowGoPayCountryCode !== 'undefined' ? rowGoPayCountryCode : null,
@@ -6977,9 +7081,6 @@ function updatePlusModeUI() {
if (!row) { if (!row) {
return; return;
} }
const selectedMethod = typeof selectPlusPaymentMethod !== 'undefined' && selectPlusPaymentMethod?.value
? normalizePlusPaymentMethod(selectPlusPaymentMethod.value)
: method;
row.style.display = enabled && selectedMethod === gopayValue ? '' : 'none'; row.style.display = enabled && selectedMethod === gopayValue ? '' : 'none';
}); });
} }
@@ -7695,6 +7796,15 @@ function applySettingsState(state) {
if (typeof inputGpcHelperPhone !== 'undefined' && inputGpcHelperPhone) { if (typeof inputGpcHelperPhone !== 'undefined' && inputGpcHelperPhone) {
inputGpcHelperPhone.value = state?.gopayHelperPhoneNumber || ''; 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) { if (typeof inputGpcHelperPin !== 'undefined' && inputGpcHelperPin) {
inputGpcHelperPin.value = state?.gopayHelperPin || ''; inputGpcHelperPin.value = state?.gopayHelperPin || '';
} }
@@ -11105,6 +11215,9 @@ selectPlusPaymentMethod?.addEventListener('change', () => {
inputGpcHelperCardKey, inputGpcHelperCardKey,
selectGpcHelperCountryCode, selectGpcHelperCountryCode,
inputGpcHelperPhone, inputGpcHelperPhone,
selectGpcHelperOtpChannel,
inputGpcHelperLocalSmsEnabled,
inputGpcHelperLocalSmsUrl,
inputGpcHelperPin, inputGpcHelperPin,
selectGoPayCountryCode, selectGoPayCountryCode,
inputGoPayPhone, inputGoPayPhone,
@@ -11115,6 +11228,16 @@ selectPlusPaymentMethod?.addEventListener('change', () => {
markSettingsDirty(true); markSettingsDirty(true);
scheduleSettingsAutoSave(); 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', () => { input?.addEventListener('blur', () => {
saveSettings({ silent: true }).catch(() => { }); saveSettings({ silent: true }).catch(() => { });
}); });
@@ -12937,7 +13060,30 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.payload.plusPaymentMethod !== undefined && selectPlusPaymentMethod) { if (message.payload.plusPaymentMethod !== undefined && selectPlusPaymentMethod) {
selectPlusPaymentMethod.value = normalizePlusPaymentMethod(message.payload.plusPaymentMethod); 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( syncStepDefinitionsForMode(
Boolean(latestState?.plusModeEnabled), Boolean(latestState?.plusModeEnabled),
latestState?.plusPaymentMethod, latestState?.plusPaymentMethod,
@@ -78,6 +78,8 @@ test('background account history settings are normalized independently from hotm
extractFunction('normalizeFiveSimMaxPrice'), extractFunction('normalizeFiveSimMaxPrice'),
extractFunction('normalizeFiveSimCountryFallback'), extractFunction('normalizeFiveSimCountryFallback'),
extractFunction('normalizeSub2ApiGroupNames'), extractFunction('normalizeSub2ApiGroupNames'),
extractFunction('normalizeBoundedIntegerSetting'),
extractFunction('normalizeLocalHttpBaseUrl'),
extractFunction('normalizePersistentSettingValue'), extractFunction('normalizePersistentSettingValue'),
].join('\n'); ].join('\n');
@@ -197,6 +199,15 @@ return {
assert.equal(api.normalizePersistentSettingValue('gopayHelperCountryCode', ' 86 '), '+86'); assert.equal(api.normalizePersistentSettingValue('gopayHelperCountryCode', ' 86 '), '+86');
assert.equal(api.normalizePersistentSettingValue('gopayHelperPhoneNumber', ' +86 138-0013-8000 '), '+8613800138000'); assert.equal(api.normalizePersistentSettingValue('gopayHelperPhoneNumber', ' +86 138-0013-8000 '), '+8613800138000');
assert.equal(api.normalizePersistentSettingValue('gopayHelperPin', ' 12-34-56 '), '123456'); 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', '7'), 7);
assert.equal(api.normalizePersistentSettingValue('verificationResendCount', '-1'), 0); assert.equal(api.normalizePersistentSettingValue('verificationResendCount', '-1'), 0);
assert.equal(api.normalizePersistentSettingValue('phoneVerificationReplacementLimit', '9'), 9); assert.equal(api.normalizePersistentSettingValue('phoneVerificationReplacementLimit', '9'), 9);
+3
View File
@@ -12,6 +12,9 @@ test('GoPay utils normalize manual OTP input', () => {
const api = loadGoPayUtils(); const api = loadGoPayUtils();
assert.equal(api.normalizeGoPayOtp(' 12-34 56 '), '123456'); assert.equal(api.normalizeGoPayOtp(' 12-34 56 '), '123456');
assert.equal(api.normalizeGoPayOtp('abc'), ''); 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', () => { test('GoPay utils keeps GPC helper payment method distinct', () => {
+81
View File
@@ -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'); 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 () => { test('GPC billing retries OTP with compatibility field after HTTP 400', async () => {
const fetchCalls = []; const fetchCalls = [];
let currentState = { let currentState = {
+46
View File
@@ -175,14 +175,60 @@ test('GPC checkout injects Plus script before reading ChatGPT session token and
type: 'gopay', type: 'gopay',
country_code: '86', country_code: '86',
phone_number: '13800138000', 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?.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?.gopayHelperReferenceId, 'ref_123');
assert.equal(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperFlowId, 'flow_789'); 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')?.step, 6);
assert.equal(events.find((event) => event.type === 'complete')?.payload?.plusCheckoutSource, 'gpc-helper'); 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 () => { test('GPC checkout treats non-zero API amount as non-free-trial and does not create order', async () => {
const markCalls = []; const markCalls = [];
const fetchCalls = []; const fetchCalls = [];
+28 -1
View File
@@ -132,6 +132,7 @@ test('sidepanel Plus UI hides PayPal account selector while GoPay is selected',
const bundle = [ const bundle = [
extractFunction('normalizePlusPaymentMethod'), extractFunction('normalizePlusPaymentMethod'),
extractFunction('getSelectedPlusPaymentMethod'), extractFunction('getSelectedPlusPaymentMethod'),
extractFunction('normalizeGpcOtpChannelValue'),
extractFunction('updatePlusModeUI'), extractFunction('updatePlusModeUI'),
].join('\n'); ].join('\n');
@@ -208,6 +209,7 @@ test('sidepanel Plus UI shows GPC fields and purchase button only for GPC withou
const bundle = [ const bundle = [
extractFunction('normalizePlusPaymentMethod'), extractFunction('normalizePlusPaymentMethod'),
extractFunction('getSelectedPlusPaymentMethod'), extractFunction('getSelectedPlusPaymentMethod'),
extractFunction('normalizeGpcOtpChannelValue'),
extractFunction('updatePlusModeUI'), extractFunction('updatePlusModeUI'),
].join('\n'); ].join('\n');
@@ -224,6 +226,11 @@ const rowGpcHelperApi = { style: { display: 'none' } };
const rowGpcHelperCardKey = { style: { display: 'none' } }; const rowGpcHelperCardKey = { style: { display: 'none' } };
const rowGpcHelperCountryCode = { style: { display: 'none' } }; const rowGpcHelperCountryCode = { style: { display: 'none' } };
const rowGpcHelperPhone = { 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 rowGpcHelperPin = { style: { display: 'none' } };
const rowGoPayCountryCode = { style: { display: 'none' } }; const rowGoPayCountryCode = { style: { display: 'none' } };
const rowGoPayPhone = { style: { display: 'none' } }; const rowGoPayPhone = { style: { display: 'none' } };
@@ -233,10 +240,12 @@ ${bundle}
return { return {
updatePlusModeUI, updatePlusModeUI,
selectPlusPaymentMethod, selectPlusPaymentMethod,
selectGpcHelperOtpChannel,
inputGpcHelperLocalSmsEnabled,
btnGpcCardKeyPurchase, btnGpcCardKeyPurchase,
rowPayPalAccount, rowPayPalAccount,
plusPaymentMethodCaption, 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.rowGpcHelperApi.style.display, 'none');
assert.equal(api.rows.rowGpcHelperCardKey.style.display, ''); assert.equal(api.rows.rowGpcHelperCardKey.style.display, '');
assert.equal(api.rows.rowGpcHelperPhone.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/); 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.selectPlusPaymentMethod.value = 'gopay';
api.updatePlusModeUI(); api.updatePlusModeUI();
assert.equal(api.btnGpcCardKeyPurchase.style.display, 'none'); assert.equal(api.btnGpcCardKeyPurchase.style.display, 'none');
+3
View File
@@ -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="input-gpc-helper-card-key"/);
assert.match(html, /id="btn-gpc-helper-balance"/); assert.match(html, /id="btn-gpc-helper-balance"/);
assert.match(html, /id="input-gpc-helper-phone"/); 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="input-gpc-helper-pin"/);
assert.match(html, /id="shared-form-modal"/); assert.match(html, /id="shared-form-modal"/);
}); });
+4 -4
View File
@@ -45,7 +45,7 @@
- 查询 GitHub Releases 并展示更新卡片;当前更新服务会区分 `Ultra`、历史 `Pro` 与 legacy `v` 三个版本族,排序时固定以 `Ultra` 为最高正式系列,同时会在读取缓存后重新排序,避免历史 `Pro``v` 版本误显示为比 `Ultra` 更新 - 查询 GitHub Releases 并展示更新卡片;当前更新服务会区分 `Ultra`、历史 `Pro` 与 legacy `v` 三个版本族,排序时固定以 `Ultra` 为最高正式系列,同时会在读取缓存后重新排序,避免历史 `Pro``v` 版本误显示为比 `Ultra` 更新
- 展示一个单独的“接码”开关、注册方式 `signupMethod` 与“接码平台”下拉;接码平台当前支持 HeroSMS / 5sim / NexSMS。普通模式下开启接码后可把注册方式切到手机号注册,并在 OAuth 登录链路命中手机号登录验证码页时继续复用同一手机号续跑短信验证 - 展示一个单独的“接码”开关、注册方式 `signupMethod` 与“接码平台”下拉;接码平台当前支持 HeroSMS / 5sim / NexSMS。普通模式下开启接码后可把注册方式切到手机号注册,并在 OAuth 登录链路命中手机号登录验证码页时继续复用同一手机号续跑短信验证
- 侧栏在接码卡内提供一个独立运行态“注册手机号”输入框,位于接码订单运行状态下方;第 2 步自动拿到号码后立即回填,用户手动接管手机号注册时也可以直接改写这一个运行态槽位。这个输入框表达账号身份,不等同于接码订单;后续自动拉短信仍依赖 `signupPhoneActivation / signupPhoneCompletedActivation` - 侧栏在接码卡内提供一个独立运行态“注册手机号”输入框,位于接码订单运行状态下方;第 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 相关字段校验与业务操作 - 为 Hotmail / 2925 账号池复用同一套“添加账号 / 取消添加 / 批量导入 / 收起列表”表单交互;共享的显隐控制放在 `sidepanel/account-pool-ui.js`,各自 manager 只保留 provider 相关字段校验与业务操作
### 2.2 Background Service Worker ### 2.2 Background Service Worker
@@ -182,7 +182,7 @@
- Codex2API 配置 - Codex2API 配置
- IP 代理持久配置:`ipProxyEnabled`、服务商、模式、API 地址、服务商配置快照、账号列表、固定 Host / Port / Protocol / Username / Password、地区参数、session 与自动切换阈值 - IP 代理持久配置:`ipProxyEnabled`、服务商、模式、API 地址、服务商配置快照、账号列表、固定 Host / Port / Protocol / Username / Password、地区参数、session 与自动切换阈值
- Plus 模式开关 `plusModeEnabled` - 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` - PayPal 账号池配置 `paypalAccounts / currentPayPalAccountId`,以及供后台步骤兼容读取的 `paypalEmail / paypalPassword`
- 邮箱 provider 配置 - 邮箱 provider 配置
- Hotmail 账号池 - Hotmail 账号池
@@ -546,8 +546,8 @@ Plus 模式通过 `plusModeEnabled` 开启,目标是在普通注册资料完
Plus 模式可见步骤: Plus 模式可见步骤:
1. 第 1~5 步:沿用普通注册入口、邮箱、密码、注册验证码、资料填写链路。 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}` 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,生成账单全名,从 `data/address-sources.js` 读取地址 seed query,触发 checkout 内置 Google 地址推荐,选择推荐项并校验地址第 1 行、城市、州/省、邮编等结构化字段,再点击“订阅”。GoPay 模式固定使用印尼地址,不根据 IP 国家推断。运行时会按 Stripe iframe 拆分执行:付款方式在 `elements-inner-payment` frame,账单地址在 `elements-inner-address` frameGoogle 推荐在 `elements-inner-autocompl` frame 时单独点击推荐项 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。 4. 第 8 步按当前支付方式显示为 `PayPal 登录与授权``GoPay 手机验证与授权`,底层 step key 仍为 `paypal-approve`。PayPal 模式后台优先读取侧边栏当前选中的 PayPal 账号;GoPay 模式读取侧边栏的国家区号、手机号、可选验证码和 PIN,先在 GoPay 页面填写手机号;验证码优先用侧边栏已填值,否则弹出插件输入框让用户手动填写,提交后继续填写 PIN。
5. 第 9 步 `订阅回跳确认`:等待 PayPal / GoPay 授权后回跳到 ChatGPT / OpenAI 页面,页面加载完成后固定等待 1 秒。 5. 第 9 步 `订阅回跳确认`:等待 PayPal / GoPay 授权后回跳到 ChatGPT / OpenAI 页面,页面加载完成后固定等待 1 秒。
6. 第 10 步:复用原 Step 7 OAuth 登录执行器,但状态和日志按 Plus 可见第 10 步记录。 6. 第 10 步:复用原 Step 7 OAuth 登录执行器,但状态和日志按 Plus 可见第 10 步记录。
+4 -2
View File
@@ -125,6 +125,7 @@
## `scripts/` ## `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 快照同步接口;旧的文本追加接口仍保留作兼容。 - `scripts/hotmail_helper.py`:本地 helper 服务,负责通过本地接口协助 Hotmail 获取邮件和验证码,并提供账号记录 JSON 快照同步接口;旧的文本追加接口仍保留作兼容。
## `sidepanel/` ## `sidepanel/`
@@ -149,7 +150,7 @@
receive 模式把账号池开关一起隐藏;当前在 `邮箱生成` 区域新增 `自定义邮箱池` 选项和多行邮箱池输入框,并在 `邮箱服务 = 自定义邮箱` receive 模式把账号池开关一起隐藏;当前在 `邮箱生成` 区域新增 `自定义邮箱池` 选项和多行邮箱池输入框,并在 `邮箱服务 = 自定义邮箱`
额外显示 `自定义号池` 文本框;当邮箱服务为 iCloud 时,额外提供目标邮箱类型与转发邮箱 provider 配置,用于选择直接从 iCloud 收件箱收 额外显示 `自定义号池` 文本框;当邮箱服务为 iCloud 时,额外提供目标邮箱类型与转发邮箱 provider 配置,用于选择直接从 iCloud 收件箱收
码或从 QQ / 网易 / Gmail 转发目标邮箱收码;来源下拉框当前支持 `CPA / SUB2API / Codex2API`,其中 Codex2API 额外提供后台地址和管理密 码或从 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 URLHotmail / 2925 两个账号池当前都使用统一的头部“添加账号/取消添加”按钮和共享表单容器;设置卡片还新增“授权总超时”开关,用于控制 Step 7 后链 5 分钟总预算。
- `sidepanel/paypal-manager.js`:侧边栏 PayPal 账号管理器,负责 Plus 模式下的账号下拉框渲染、添加账号弹窗、保存账号与切换当前账号。 - `sidepanel/paypal-manager.js`:侧边栏 PayPal 账号管理器,负责 Plus 模式下的账号下拉框渲染、添加账号弹窗、保存账号与切换当前账号。
- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、共享验证码自动重发次数配置与广播接收,并装 - `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、共享验证码自动重发次数配置与广播接收,并装
配 Hotmail / 2925 / iCloud / LuckMail / 贡献模式 / 账号记录面板 / 贡献内容更新服务;当前贡献模式的“开始贡献”会直接复用主自动流程启 配 Hotmail / 2925 / iCloud / LuckMail / 贡献模式 / 账号记录面板 / 贡献内容更新服务;当前贡献模式的“开始贡献”会直接复用主自动流程启
@@ -213,7 +214,8 @@
- `tests/phone-verification-flow.test.js`:测试手机号验证共享流程对 HeroSMS 的取号、复用、重发、换号与 Step 7 重开错误分支。 - `tests/phone-verification-flow.test.js`:测试手机号验证共享流程对 HeroSMS 的取号、复用、重发、换号与 Step 7 重开错误分支。
- `tests/paypal-approve-detection.test.js`:测试 Plus 第 8 步后台执行器对 PayPal 标签页发现、分离式账号/密码页识别、联合登录页识别,以及登录后直接离开 PayPal 页的分支判断。 - `tests/paypal-approve-detection.test.js`:测试 Plus 第 8 步后台执行器对 PayPal 标签页发现、分离式账号/密码页识别、联合登录页识别,以及登录后直接离开 PayPal 页的分支判断。
- `tests/paypal-flow-content.test.js`:测试 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-provider.test.js`:测试 Cloudflare Temp Email provider 的轮询与目标邮箱选择逻辑。
- `tests/cloudflare-temp-email-utils.test.js`:测试 Cloudflare Temp Email 工具层的 URL、域名、邮件解析逻辑。 - `tests/cloudflare-temp-email-utils.test.js`:测试 Cloudflare Temp Email 工具层的 URL、域名、邮件解析逻辑。
- `tests/content-utils.test.js`:测试内容脚本公共工具层对 `mail.126.com` 等网页邮箱来源的识别逻辑。 - `tests/content-utils.test.js`:测试内容脚本公共工具层对 `mail.126.com` 等网页邮箱来源的识别逻辑。