fix: query local GPC OTP helper by phone

- 合并 PR #211 的核心改动:GPC 本地 OTP helper 改为按 GPC 手机号查询 /latest-otp,并让后台携带 E.164 phone 与 consume=1。
- 本地补充修复:将 consume 行为收敛为只消费本次返回的 OTP 记录,避免同手机号后续验证码被误删,并同步文档说明。
- 影响范围:GPC Plus 第 7 步队列任务 OTP 提交、本地 macOS SMS helper、相关回归测试与链路文档。
This commit is contained in:
QLHazyCoder
2026-05-07 13:24:17 +08:00
committed by GitHub
6 changed files with 318 additions and 38 deletions
+41 -3
View File
@@ -463,11 +463,48 @@
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 = {}) {
const baseUrl = normalizeLocalSmsHelperBaseUrl(state?.gopayHelperLocalSmsHelperUrl);
const url = new URL(`${baseUrl}/otp`);
const url = new URL(`${baseUrl}/latest-otp`);
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 orderCreatedAt = normalizeEpochMilliseconds(
state?.gopayHelperOrderCreatedAt
@@ -483,8 +520,9 @@
url.searchParams.set('reference_id', normalizedTaskId);
}
if (phoneNumber) {
url.searchParams.set('phone_number', phoneNumber);
url.searchParams.set('phone', phoneNumber);
}
url.searchParams.set('consume', '1');
const effectiveAfterMs = Math.max(orderCreatedAt, afterOverrideMs);
if (effectiveAfterMs > 0) {
url.searchParams.set('after_ms', String(effectiveAfterMs));
+136 -24
View File
@@ -74,6 +74,11 @@ def extract_gopay_otp(text: str, require_keywords: bool = True) -> Optional[str]
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:
if not value:
return dt.datetime.now(dt.timezone.utc)
@@ -113,24 +118,52 @@ def read_recent_messages(db_path: Path, after_rowid: int = 0, limit: int = 80) -
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()
params = (max(0, int(after_rowid or 0)), max(1, int(limit or 80)))
try:
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.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
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 ?
""",
params,
).fetchall()
return [dict(row) for row in rows]
finally:
try:
@@ -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)
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:
received_at = mac_message_time_to_datetime(row.get("date")).isoformat()
phone_e164 = get_record_phone(row)
return {
"otp": otp,
"code": otp,
@@ -149,6 +191,8 @@ def make_otp_record(row: dict, otp: str) -> dict:
"rowid": int(row.get("rowid") or 0),
"sender": str(row.get("handle") or ""),
"service": str(row.get("service") or ""),
"account_phone": phone_e164,
"phone_e164": phone_e164,
"received_at": received_at,
"message_text": str(row.get("text") or ""),
}
@@ -189,19 +233,86 @@ def parse_timestamp_ms(value: object) -> int:
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")
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:
if (
isinstance(record, dict)
and record_matches_phone(record, phone)
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):
if isinstance(record, dict) and record_matches_phone(record, phone):
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 is_consumed_record(item: object) -> bool:
if not isinstance(item, dict):
return False
if consumed_message_id and str(item.get("message_id") or "").strip() == consumed_message_id:
return True
if consumed_rowid and int(item.get("rowid") or 0) == consumed_rowid:
return True
return False
def is_same_record(left: object, right: object) -> bool:
if not isinstance(left, dict) or not isinstance(right, dict):
return False
left_message_id = str(left.get("message_id") or "").strip()
right_message_id = str(right.get("message_id") or "").strip()
if left_message_id and right_message_id and left_message_id == right_message_id:
return True
left_rowid = int(left.get("rowid") or 0)
right_rowid = int(right.get("rowid") or 0)
if left_rowid > 0 and right_rowid > 0 and left_rowid == right_rowid:
return True
return left == right
with STATE_LOCK:
records = STATE.get("otps")
if not isinstance(records, list):
records = []
if consumed_message_id or consumed_rowid:
next_records = [item for item in records if isinstance(item, dict) and not is_consumed_record(item)]
elif wanted:
removed_once = False
next_records = []
for item in records:
if not isinstance(item, dict):
continue
if not removed_once and record_matches_phone(item, wanted):
removed_once = True
continue
next_records.append(item)
else:
next_records = []
STATE["otps"] = next_records
last_otp = STATE.get("last_otp")
if isinstance(last_otp, dict) and not any(is_same_record(last_otp, item) for item in STATE["otps"]):
STATE["last_otp"] = STATE["otps"][0] if STATE["otps"] else None
def scan_once(db_path: Path, require_keywords: bool = True) -> None:
@@ -263,14 +374,15 @@ class HelperHandler(BaseHTTPRequestHandler):
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])
phone = str((query.get("phone") or query.get("phone_e164") or query.get("phone_number") or [""])[0]).strip()
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:
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)
consume_otp_record(phone=phone, record=record)
write_json(self, 200, payload)
return
write_json(self, 404, {"ok": False, "error": "not_found"})
+120
View File
@@ -79,3 +79,123 @@ print(json.dumps(payload))
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',
});
});
test('GPC SMS helper consume keeps newer same-phone OTP when consuming an older record', () => {
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_old = {
"otp": "111111",
"code": "111111",
"message_id": "old",
"rowid": 1,
"phone_e164": "+8613800138000",
"account_phone": "+8613800138000",
"received_at": "2026-05-05T00:00:00+00:00",
}
record_new = {
"otp": "222222",
"code": "222222",
"message_id": "new",
"rowid": 2,
"phone_e164": "+8613800138000",
"account_phone": "+8613800138000",
"received_at": "2026-05-05T00:00:10+00:00",
}
record_other = {
"otp": "333333",
"code": "333333",
"message_id": "other",
"rowid": 3,
"phone_e164": "+8618984829950",
"account_phone": "+8618984829950",
"received_at": "2026-05-05T00:00:20+00:00",
}
module.STATE.update({"last_otp": record_new, "otps": [record_new, record_old, record_other]})
module.consume_otp_record(phone="+8613800138000", record=record_old)
state_after = module.get_state()
payload = {
"same_phone_after": module.select_otp_record(state_after, phone="+8613800138000")["otp"],
"other_phone_after": module.select_otp_record(state_after, phone="+8618984829950")["otp"],
"records_after": [item["message_id"] for item in state_after.get("otps", [])],
"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()), {
same_phone_after: '222222',
other_phone_after: '333333',
records_after: ['new', 'other'],
global_after: '222222',
});
});
@@ -916,7 +916,7 @@ test('GPC billing reads SMS OTP from local helper for sms_otp_wait', async () =>
stateByFrame: {},
fetchImpl: async (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 {
ok: true,
status: 200,
@@ -973,17 +973,19 @@ test('GPC billing reads SMS OTP from local helper for sms_otp_wait', async () =>
gopayHelperOtpChannel: 'sms',
gopayHelperLocalSmsHelperEnabled: true,
gopayHelperLocalSmsHelperUrl: 'http://127.0.0.1:18767',
gopayHelperPhoneNumber: '+8613800138000',
gopayHelperCountryCode: '+86',
gopayHelperPhoneNumber: '13800138000',
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[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('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.deepEqual(JSON.parse(fetchCalls.find((call) => call.url.endsWith('/api/gp/tasks/task_sms/otp')).options.body), {
otp: '654321',
@@ -999,7 +1001,7 @@ test('GPC billing can read WhatsApp OTP from local helper when enabled', async (
stateByFrame: {},
fetchImpl: async (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 {
ok: true,
status: 200,
@@ -1056,11 +1058,15 @@ test('GPC billing can read WhatsApp OTP from local helper when enabled', async (
gopayHelperOtpChannel: 'whatsapp',
gopayHelperLocalSmsHelperEnabled: true,
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.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), {
otp: '765432',
});
@@ -1075,7 +1081,7 @@ test('GPC billing helper mode does not open OTP dialog when helper has no code a
stateByFrame: {},
fetchImpl: async (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 {
ok: true,
status: 200,
@@ -1139,7 +1145,7 @@ test('GPC billing helper mode requests newer OTP after invalid OTP error', async
stateByFrame: {},
fetchImpl: async (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;
return {
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));
assert.deepEqual(otpBodies, [{ otp: '111111' }, { otp: '222222' }]);
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[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[1].searchParams.get('phone'), '+8613800138000');
assert.equal(helperUrls[1].searchParams.get('consume'), '1');
assert.ok(Number(helperUrls[1].searchParams.get('after_ms')) > 1710000000000);
assert.equal(events.logs.some((entry) => /OTP 校验失败/.test(entry.message)), true);
assert.equal(events.completed[0].step, 7);
+1 -1
View File
@@ -549,7 +549,7 @@ Plus 模式可见步骤:
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`
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`
5. 第 9 步 `订阅回跳确认` 仅在 PayPal / GoPay 模式下等待授权后回跳到 ChatGPT / OpenAI 页面,页面加载完成后固定等待 1 秒。GPC helper 模式在第 7 步任务完成后会直接进入 Plus 可见第 10 步 OAuth 登录。
6. 第 10 步:复用原 Step 7 OAuth 登录执行器,但状态和日志按 Plus 可见第 10 步记录。
+1 -1
View File
@@ -126,7 +126,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/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 快照同步接口;旧的文本追加接口仍保留作兼容。
## `sidepanel/`