fix(gpc): query local OTP helper by phone

This commit is contained in:
朴圣佑
2026-05-07 12:12:57 +08:00
parent 250a709f20
commit 2236730374
6 changed files with 227 additions and 38 deletions
+41 -3
View File
@@ -463,11 +463,48 @@
return Number.isFinite(parsed) ? parsed : 0; return Number.isFinite(parsed) ? parsed : 0;
} }
function normalizeLocalSmsHelperCountryCode(value = '') {
const rootScope = typeof self !== 'undefined' ? self : globalThis;
if (rootScope.GoPayUtils?.normalizeGoPayCountryCode) {
return rootScope.GoPayUtils.normalizeGoPayCountryCode(value || '+86');
}
const digits = String(value || '+86').replace(/\D/g, '');
return digits ? `+${digits}` : '+86';
}
function normalizeLocalSmsHelperPhoneE164(phone = '', countryCode = '+86') {
const rawPhone = String(phone || '').trim();
if (!rawPhone) {
return '';
}
const rootScope = typeof self !== 'undefined' ? self : globalThis;
const normalizedCountryCode = normalizeLocalSmsHelperCountryCode(countryCode);
const normalizedPhone = rootScope.GoPayUtils?.normalizeGoPayPhone
? rootScope.GoPayUtils.normalizeGoPayPhone(rawPhone)
: rawPhone.replace(/[^\d+]/g, '');
const phoneDigits = normalizedPhone.replace(/\D/g, '');
if (!phoneDigits) {
return '';
}
if (normalizedPhone.startsWith('+')) {
return `+${phoneDigits}`;
}
const countryDigits = normalizedCountryCode.replace(/\D/g, '') || '86';
let nationalNumber = phoneDigits;
if (countryDigits && nationalNumber.startsWith(countryDigits) && nationalNumber.length > countryDigits.length) {
nationalNumber = nationalNumber.slice(countryDigits.length);
}
return `+${countryDigits}${nationalNumber}`;
}
function buildLocalSmsHelperOtpUrl(state = {}, taskId = '', options = {}) { function buildLocalSmsHelperOtpUrl(state = {}, taskId = '', options = {}) {
const baseUrl = normalizeLocalSmsHelperBaseUrl(state?.gopayHelperLocalSmsHelperUrl); const baseUrl = normalizeLocalSmsHelperBaseUrl(state?.gopayHelperLocalSmsHelperUrl);
const url = new URL(`${baseUrl}/otp`); const url = new URL(`${baseUrl}/latest-otp`);
const normalizedTaskId = String(taskId || '').trim(); const normalizedTaskId = String(taskId || '').trim();
const phoneNumber = String(state?.gopayHelperPhoneNumber || '').trim(); const phoneNumber = normalizeLocalSmsHelperPhoneE164(
state?.gopayHelperPhoneNumber,
state?.gopayHelperCountryCode || '+86'
);
const afterOverrideMs = normalizeEpochMilliseconds(options?.afterMs || options?.after_ms || 0); const afterOverrideMs = normalizeEpochMilliseconds(options?.afterMs || options?.after_ms || 0);
const orderCreatedAt = normalizeEpochMilliseconds( const orderCreatedAt = normalizeEpochMilliseconds(
state?.gopayHelperOrderCreatedAt state?.gopayHelperOrderCreatedAt
@@ -483,8 +520,9 @@
url.searchParams.set('reference_id', normalizedTaskId); url.searchParams.set('reference_id', normalizedTaskId);
} }
if (phoneNumber) { if (phoneNumber) {
url.searchParams.set('phone_number', phoneNumber); url.searchParams.set('phone', phoneNumber);
} }
url.searchParams.set('consume', '1');
const effectiveAfterMs = Math.max(orderCreatedAt, afterOverrideMs); const effectiveAfterMs = Math.max(orderCreatedAt, afterOverrideMs);
if (effectiveAfterMs > 0) { if (effectiveAfterMs > 0) {
url.searchParams.set('after_ms', String(effectiveAfterMs)); url.searchParams.set('after_ms', String(effectiveAfterMs));
+92 -7
View File
@@ -74,6 +74,11 @@ def extract_gopay_otp(text: str, require_keywords: bool = True) -> Optional[str]
return None return None
def normalize_phone_key(value: object) -> str:
digits = re.sub(r"\D+", "", str(value or ""))
return f"+{digits}" if digits else ""
def mac_message_time_to_datetime(value: int | float | None) -> dt.datetime: def mac_message_time_to_datetime(value: int | float | None) -> dt.datetime:
if not value: if not value:
return dt.datetime.now(dt.timezone.utc) return dt.datetime.now(dt.timezone.utc)
@@ -113,6 +118,8 @@ def read_recent_messages(db_path: Path, after_rowid: int = 0, limit: int = 80) -
try: try:
conn = sqlite3.connect(str(copied)) conn = sqlite3.connect(str(copied))
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
params = (max(0, int(after_rowid or 0)), max(1, int(limit or 80)))
try:
rows = conn.execute( rows = conn.execute(
""" """
SELECT SELECT
@@ -121,6 +128,32 @@ def read_recent_messages(db_path: Path, after_rowid: int = 0, limit: int = 80) -
message.text AS text, message.text AS text,
message.date AS date, message.date AS date,
message.service AS service, message.service AS service,
message.destination_caller_id AS destination_caller_id,
message.account AS account,
handle.id AS handle,
chat.last_addressed_handle AS last_addressed_handle,
chat.account_login AS account_login
FROM message
LEFT JOIN handle ON message.handle_id = handle.ROWID
LEFT JOIN chat_message_join ON chat_message_join.message_id = message.ROWID
LEFT JOIN chat ON chat.ROWID = chat_message_join.chat_id
WHERE message.ROWID > ?
AND message.text IS NOT NULL
ORDER BY message.ROWID DESC
LIMIT ?
""",
params,
).fetchall()
except sqlite3.OperationalError:
rows = conn.execute(
"""
SELECT
message.ROWID AS rowid,
message.guid AS guid,
message.text AS text,
message.date AS date,
message.service AS service,
message.account AS account,
handle.id AS handle handle.id AS handle
FROM message FROM message
LEFT JOIN handle ON message.handle_id = handle.ROWID LEFT JOIN handle ON message.handle_id = handle.ROWID
@@ -129,7 +162,7 @@ def read_recent_messages(db_path: Path, after_rowid: int = 0, limit: int = 80) -
ORDER BY message.ROWID DESC ORDER BY message.ROWID DESC
LIMIT ? LIMIT ?
""", """,
(max(0, int(after_rowid or 0)), max(1, int(limit or 80))), params,
).fetchall() ).fetchall()
return [dict(row) for row in rows] return [dict(row) for row in rows]
finally: finally:
@@ -140,8 +173,17 @@ def read_recent_messages(db_path: Path, after_rowid: int = 0, limit: int = 80) -
shutil.rmtree(copied.parent, ignore_errors=True) shutil.rmtree(copied.parent, ignore_errors=True)
def get_record_phone(row: dict) -> str:
for key in ("destination_caller_id", "last_addressed_handle", "account_login", "account"):
phone = normalize_phone_key(row.get(key))
if phone:
return phone
return ""
def make_otp_record(row: dict, otp: str) -> dict: def make_otp_record(row: dict, otp: str) -> dict:
received_at = mac_message_time_to_datetime(row.get("date")).isoformat() received_at = mac_message_time_to_datetime(row.get("date")).isoformat()
phone_e164 = get_record_phone(row)
return { return {
"otp": otp, "otp": otp,
"code": otp, "code": otp,
@@ -149,6 +191,8 @@ def make_otp_record(row: dict, otp: str) -> dict:
"rowid": int(row.get("rowid") or 0), "rowid": int(row.get("rowid") or 0),
"sender": str(row.get("handle") or ""), "sender": str(row.get("handle") or ""),
"service": str(row.get("service") or ""), "service": str(row.get("service") or ""),
"account_phone": phone_e164,
"phone_e164": phone_e164,
"received_at": received_at, "received_at": received_at,
"message_text": str(row.get("text") or ""), "message_text": str(row.get("text") or ""),
} }
@@ -189,19 +233,59 @@ def parse_timestamp_ms(value: object) -> int:
return int(numeric) return int(numeric)
def select_otp_record(state: dict, after_ms: int = 0) -> Optional[dict]: def record_matches_phone(record: dict, phone: str = "") -> bool:
wanted = normalize_phone_key(phone)
if not wanted:
return True
return normalize_phone_key(record.get("phone_e164") or record.get("account_phone")) == wanted
def select_otp_record(state: dict, after_ms: int = 0, phone: str = "") -> Optional[dict]:
records = state.get("otps") records = state.get("otps")
if not isinstance(records, list): if not isinstance(records, list):
records = [] records = []
if after_ms > 0: if after_ms > 0:
for record in records: for record in records:
if isinstance(record, dict) and parse_timestamp_ms(record.get("received_at")) >= after_ms: if (
isinstance(record, dict)
and record_matches_phone(record, phone)
and parse_timestamp_ms(record.get("received_at")) >= after_ms
):
return record return record
return None return None
record = state.get("last_otp") or None record = state.get("last_otp") or None
if isinstance(record, dict): if isinstance(record, dict) and record_matches_phone(record, phone):
return record return record
return records[0] if records and isinstance(records[0], dict) else None for record in records:
if isinstance(record, dict) and record_matches_phone(record, phone):
return record
return None
def consume_otp_record(phone: str = "", record: Optional[dict] = None) -> None:
wanted = normalize_phone_key(phone)
consumed_message_id = str((record or {}).get("message_id") or "").strip()
consumed_rowid = int((record or {}).get("rowid") or 0)
def should_keep(item: object) -> bool:
if not isinstance(item, dict):
return False
if wanted:
return not record_matches_phone(item, wanted)
if consumed_message_id:
return str(item.get("message_id") or "").strip() != consumed_message_id
if consumed_rowid:
return int(item.get("rowid") or 0) != consumed_rowid
return False
with STATE_LOCK:
records = STATE.get("otps")
if not isinstance(records, list):
records = []
STATE["otps"] = [item for item in records if should_keep(item)]
last_otp = STATE.get("last_otp")
if isinstance(last_otp, dict) and not should_keep(last_otp):
STATE["last_otp"] = STATE["otps"][0] if STATE["otps"] else None
def scan_once(db_path: Path, require_keywords: bool = True) -> None: def scan_once(db_path: Path, require_keywords: bool = True) -> None:
@@ -263,14 +347,15 @@ class HelperHandler(BaseHTTPRequestHandler):
query = parse_qs(parsed.query) query = parse_qs(parsed.query)
consume = str(query.get("consume", ["0"])[0]).strip().lower() in {"1", "true", "yes"} 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]) after_ms = parse_timestamp_ms(query.get("after_ms", query.get("after", ["0"]))[0])
phone = str((query.get("phone") or query.get("phone_e164") or query.get("phone_number") or [""])[0]).strip()
state = get_state() state = get_state()
record = select_otp_record(state, after_ms=after_ms) record = select_otp_record(state, after_ms=after_ms, phone=phone)
if not record: if not record:
write_json(self, 200, {"ok": True, "otp": "", "code": "", "status": "waiting", "message": "未查询到验证码"}) write_json(self, 200, {"ok": True, "otp": "", "code": "", "status": "waiting", "message": "未查询到验证码"})
return return
payload = {"ok": True, "status": "found", **record} payload = {"ok": True, "status": "found", **record}
if consume: if consume:
update_state(last_otp=None) consume_otp_record(phone=phone, record=record)
write_json(self, 200, payload) write_json(self, 200, payload)
return return
write_json(self, 404, {"ok": False, "error": "not_found"}) write_json(self, 404, {"ok": False, "error": "not_found"})
+56
View File
@@ -79,3 +79,59 @@ print(json.dumps(payload))
none_after_fresh: true, none_after_fresh: true,
}); });
}); });
test('GPC SMS helper selects and consumes cached OTP records by phone', () => {
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)
record_a = {
"otp": "111111",
"code": "111111",
"message_id": "a",
"rowid": 1,
"phone_e164": "+8615808505050",
"account_phone": "+8615808505050",
"received_at": "2026-05-05T00:00:00+00:00",
}
record_b = {
"otp": "222222",
"code": "222222",
"message_id": "b",
"rowid": 2,
"phone_e164": "+8618984829950",
"account_phone": "+8618984829950",
"received_at": "2026-05-05T00:00:10+00:00",
}
module.STATE.update({"last_otp": record_b, "otps": [record_b, record_a]})
selected_a = module.select_otp_record(module.get_state(), phone="+8615808505050")
module.consume_otp_record(phone="+8615808505050", record=selected_a)
state_after = module.get_state()
payload = {
"selected_a": selected_a["otp"],
"selected_b_after": module.select_otp_record(state_after, phone="+8618984829950")["otp"],
"selected_a_after": module.select_otp_record(state_after, phone="+8615808505050") is None,
"global_after": module.select_otp_record(state_after)["otp"],
}
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()), {
selected_a: '111111',
selected_b_after: '222222',
selected_a_after: true,
global_after: '222222',
});
});
@@ -916,7 +916,7 @@ test('GPC billing reads SMS OTP from local helper for sms_otp_wait', async () =>
stateByFrame: {}, stateByFrame: {},
fetchImpl: async (url, options = {}) => { fetchImpl: async (url, options = {}) => {
fetchCalls.push({ url, options }); fetchCalls.push({ url, options });
if (url.startsWith('http://127.0.0.1:18767/otp')) { if (url.startsWith('http://127.0.0.1:18767/latest-otp')) {
return { return {
ok: true, ok: true,
status: 200, status: 200,
@@ -973,17 +973,19 @@ test('GPC billing reads SMS OTP from local helper for sms_otp_wait', async () =>
gopayHelperOtpChannel: 'sms', gopayHelperOtpChannel: 'sms',
gopayHelperLocalSmsHelperEnabled: true, gopayHelperLocalSmsHelperEnabled: true,
gopayHelperLocalSmsHelperUrl: 'http://127.0.0.1:18767', gopayHelperLocalSmsHelperUrl: 'http://127.0.0.1:18767',
gopayHelperPhoneNumber: '+8613800138000', gopayHelperCountryCode: '+86',
gopayHelperPhoneNumber: '13800138000',
gopayHelperOrderCreatedAt: 1710000000000, gopayHelperOrderCreatedAt: 1710000000000,
}); });
assert.equal(events.states.some((state) => state.plusManualConfirmationMethod === 'gopay-otp'), false); assert.equal(events.states.some((state) => state.plusManualConfirmationMethod === 'gopay-otp'), false);
assert.equal(events.states.some((state) => state.gopayHelperResolvedOtp === '654321'), true); assert.equal(events.states.some((state) => state.gopayHelperResolvedOtp === '654321'), true);
const helperUrl = new URL(fetchCalls[1].url); const helperUrl = new URL(fetchCalls[1].url);
assert.equal(helperUrl.origin + helperUrl.pathname, 'http://127.0.0.1:18767/otp'); assert.equal(helperUrl.origin + helperUrl.pathname, 'http://127.0.0.1:18767/latest-otp');
assert.equal(helperUrl.searchParams.get('task_id'), 'task_sms'); assert.equal(helperUrl.searchParams.get('task_id'), 'task_sms');
assert.equal(helperUrl.searchParams.get('reference_id'), 'task_sms'); assert.equal(helperUrl.searchParams.get('reference_id'), 'task_sms');
assert.equal(helperUrl.searchParams.get('phone_number'), '+8613800138000'); assert.equal(helperUrl.searchParams.get('phone'), '+8613800138000');
assert.equal(helperUrl.searchParams.get('consume'), '1');
assert.equal(helperUrl.searchParams.get('after_ms'), '1710000000000'); assert.equal(helperUrl.searchParams.get('after_ms'), '1710000000000');
assert.deepEqual(JSON.parse(fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_sms/otp')).options.body), { assert.deepEqual(JSON.parse(fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_sms/otp')).options.body), {
otp: '654321', otp: '654321',
@@ -999,7 +1001,7 @@ test('GPC billing can read WhatsApp OTP from local helper when enabled', async (
stateByFrame: {}, stateByFrame: {},
fetchImpl: async (url, options = {}) => { fetchImpl: async (url, options = {}) => {
fetchCalls.push({ url, options }); fetchCalls.push({ url, options });
if (url.startsWith('http://127.0.0.1:18767/otp')) { if (url.startsWith('http://127.0.0.1:18767/latest-otp')) {
return { return {
ok: true, ok: true,
status: 200, status: 200,
@@ -1056,11 +1058,15 @@ test('GPC billing can read WhatsApp OTP from local helper when enabled', async (
gopayHelperOtpChannel: 'whatsapp', gopayHelperOtpChannel: 'whatsapp',
gopayHelperLocalSmsHelperEnabled: true, gopayHelperLocalSmsHelperEnabled: true,
gopayHelperLocalSmsHelperUrl: 'http://127.0.0.1:18767', gopayHelperLocalSmsHelperUrl: 'http://127.0.0.1:18767',
gopayHelperPhoneNumber: '+8613800138000', gopayHelperCountryCode: '+86',
gopayHelperPhoneNumber: '18984829950',
}); });
assert.equal(events.states.some((state) => state.plusManualConfirmationMethod === 'gopay-otp'), false); assert.equal(events.states.some((state) => state.plusManualConfirmationMethod === 'gopay-otp'), false);
assert.equal(events.states.some((state) => state.gopayHelperResolvedOtp === '765432'), true); assert.equal(events.states.some((state) => state.gopayHelperResolvedOtp === '765432'), true);
const helperUrl = new URL(fetchCalls.find((call) => call.url.startsWith('http://127.0.0.1:18767/latest-otp')).url);
assert.equal(helperUrl.searchParams.get('phone'), '+8618984829950');
assert.equal(helperUrl.searchParams.get('consume'), '1');
assert.deepEqual(JSON.parse(fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_wa/otp')).options.body), { assert.deepEqual(JSON.parse(fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_wa/otp')).options.body), {
otp: '765432', otp: '765432',
}); });
@@ -1075,7 +1081,7 @@ test('GPC billing helper mode does not open OTP dialog when helper has no code a
stateByFrame: {}, stateByFrame: {},
fetchImpl: async (url, options = {}) => { fetchImpl: async (url, options = {}) => {
fetchCalls.push({ url, options }); fetchCalls.push({ url, options });
if (url.startsWith('http://127.0.0.1:18767/otp')) { if (url.startsWith('http://127.0.0.1:18767/latest-otp')) {
return { return {
ok: true, ok: true,
status: 200, status: 200,
@@ -1139,7 +1145,7 @@ test('GPC billing helper mode requests newer OTP after invalid OTP error', async
stateByFrame: {}, stateByFrame: {},
fetchImpl: async (url, options = {}) => { fetchImpl: async (url, options = {}) => {
fetchCalls.push({ url, options }); fetchCalls.push({ url, options });
if (url.startsWith('http://127.0.0.1:18767/otp')) { if (url.startsWith('http://127.0.0.1:18767/latest-otp')) {
helperCallCount += 1; helperCallCount += 1;
return { return {
ok: true, ok: true,
@@ -1190,9 +1196,13 @@ test('GPC billing helper mode requests newer OTP after invalid OTP error', async
.map((call) => JSON.parse(call.options.body)); .map((call) => JSON.parse(call.options.body));
assert.deepEqual(otpBodies, [{ otp: '111111' }, { otp: '222222' }]); assert.deepEqual(otpBodies, [{ otp: '111111' }, { otp: '222222' }]);
assert.equal(otpPostCount, 2); assert.equal(otpPostCount, 2);
const helperUrls = fetchCalls.filter((call) => call.url.startsWith('http://127.0.0.1:18767/otp')).map((call) => new URL(call.url)); const helperUrls = fetchCalls.filter((call) => call.url.startsWith('http://127.0.0.1:18767/latest-otp')).map((call) => new URL(call.url));
assert.equal(helperUrls.length, 2); assert.equal(helperUrls.length, 2);
assert.equal(helperUrls[0].searchParams.get('phone'), '+8613800138000');
assert.equal(helperUrls[0].searchParams.get('consume'), '1');
assert.equal(helperUrls[0].searchParams.get('after_ms'), '1710000000000'); assert.equal(helperUrls[0].searchParams.get('after_ms'), '1710000000000');
assert.equal(helperUrls[1].searchParams.get('phone'), '+8613800138000');
assert.equal(helperUrls[1].searchParams.get('consume'), '1');
assert.ok(Number(helperUrls[1].searchParams.get('after_ms')) > 1710000000000); assert.ok(Number(helperUrls[1].searchParams.get('after_ms')) > 1710000000000);
assert.equal(events.logs.some((entry) => /OTP 校验失败/.test(entry.message)), true); assert.equal(events.logs.some((entry) => /OTP 校验失败/.test(entry.message)), true);
assert.equal(events.completed[0].step, 7); assert.equal(events.completed[0].step, 7);
+1 -1
View File
@@ -549,7 +549,7 @@ Plus 模式可见步骤:
1. 第 1~5 步:沿用普通注册入口、邮箱、密码、注册验证码、资料填写链路。 1. 第 1~5 步:沿用普通注册入口、邮箱、密码、注册验证码、资料填写链路。
2. 第 6 步 `创建 Plus Checkout`PayPal / GoPay 打开已登录 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` 等提交到 `gopayHelperApiUrl``/api/gp/tasks`,并通过 `gopayHelperApiKey` 发送 `X-API-Key` 认证,创建后保存 `task_id` 2. 第 6 步 `创建 Plus Checkout`PayPal / GoPay 打开已登录 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` 等提交到 `gopayHelperApiUrl``/api/gp/tasks`,并通过 `gopayHelperApiKey` 发送 `X-API-Key` 认证,创建后保存 `task_id`
3. 第 7 步 `填写账单并提交订阅`PayPal / GoPay 仍走 checkout 页面与 Stripe iframe 自动化;GPC helper 模式不再操作 checkout iframe,而是轮询 `/api/gp/tasks/{task_id}`,根据远端 `api_waiting_for` 依次向 `/otp``/pin` 提交验证码与 PIN。若侧栏启用本地 OTP helper,后台轮询 `gopayHelperLocalSmsHelperUrl``/otp` 接口读取当前 OTP 通道验证码,不再弹手动验证码;未开启本地 helper 时才弹出手动 OTP 输入框。仓库内置的 `scripts/gpc_sms_helper_macos.py` 是 macOS Messages 短信读取实现,其他通道需要提供兼容的本地 `/otp` 接口。 3. 第 7 步 `填写账单并提交订阅`PayPal / GoPay 仍走 checkout 页面与 Stripe iframe 自动化;GPC helper 模式不再操作 checkout iframe,而是轮询 `/api/gp/tasks/{task_id}`,根据远端 `api_waiting_for` 依次向 `/otp``/pin` 提交验证码与 PIN。若侧栏启用本地 OTP helper,后台轮询 `gopayHelperLocalSmsHelperUrl``/latest-otp?phone=...&consume=1` 接口,按当前 GPC 手机号读取并消费当前 OTP 通道的验证码;未开启本地 helper 时才弹出手动 OTP 输入框。仓库内置的 `scripts/gpc_sms_helper_macos.py` 是 macOS Messages/本地通知兼容读取实现,其他通道需要提供兼容的本地 `/latest-otp` `/otp` 接口。
4. 仅 PayPal / GoPay 会继续显示第 8 步:该步按当前支付方式显示为 `PayPal 登录与授权``GoPay 手机验证与授权`,底层 step key 仍为 `paypal-approve` 4. 仅 PayPal / GoPay 会继续显示第 8 步:该步按当前支付方式显示为 `PayPal 登录与授权``GoPay 手机验证与授权`,底层 step key 仍为 `paypal-approve`
5. 第 9 步 `订阅回跳确认` 仅在 PayPal / GoPay 模式下等待授权后回跳到 ChatGPT / OpenAI 页面,页面加载完成后固定等待 1 秒。GPC helper 模式在第 7 步任务完成后会直接进入 Plus 可见第 10 步 OAuth 登录。 5. 第 9 步 `订阅回跳确认` 仅在 PayPal / GoPay 模式下等待授权后回跳到 ChatGPT / OpenAI 页面,页面加载完成后固定等待 1 秒。GPC helper 模式在第 7 步任务完成后会直接进入 Plus 可见第 10 步 OAuth 登录。
6. 第 10 步:复用原 Step 7 OAuth 登录执行器,但状态和日志按 Plus 可见第 10 步记录。 6. 第 10 步:复用原 Step 7 OAuth 登录执行器,但状态和日志按 Plus 可见第 10 步记录。
+1 -1
View File
@@ -126,7 +126,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/gpc_sms_helper_macos.py`GPC Plus helper 的 macOS 本地短信验证码 helper,读取本机 Messages 数据库中的 GoPay/OpenAI 短信 OTP,并通过 `http://127.0.0.1:18767/latest-otp?phone=...&consume=1` 提供给扩展按手机号自动轮询;`/otp` 仍保留兼容,`consume=1` 会清掉对应手机号的缓存验证码,避免下次重复读取
- `scripts/hotmail_helper.py`:本地 helper 服务,负责通过本地接口协助 Hotmail 获取邮件和验证码,并提供账号记录 JSON 快照同步接口;旧的文本追加接口仍保留作兼容。 - `scripts/hotmail_helper.py`:本地 helper 服务,负责通过本地接口协助 Hotmail 获取邮件和验证码,并提供账号记录 JSON 快照同步接口;旧的文本追加接口仍保留作兼容。
## `sidepanel/` ## `sidepanel/`