diff --git a/background/steps/fill-plus-checkout.js b/background/steps/fill-plus-checkout.js index 2952b96..7cd00c0 100644 --- a/background/steps/fill-plus-checkout.js +++ b/background/steps/fill-plus-checkout.js @@ -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)); diff --git a/scripts/gpc_sms_helper_macos.py b/scripts/gpc_sms_helper_macos.py index 65092b9..7300bf4 100644 --- a/scripts/gpc_sms_helper_macos.py +++ b/scripts/gpc_sms_helper_macos.py @@ -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"}) diff --git a/tests/gpc-sms-helper-script.test.js b/tests/gpc-sms-helper-script.test.js index 79574d4..f3ebb59 100644 --- a/tests/gpc-sms-helper-script.test.js +++ b/tests/gpc-sms-helper-script.test.js @@ -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', + }); +}); diff --git a/tests/plus-checkout-billing-tab-resolution.test.js b/tests/plus-checkout-billing-tab-resolution.test.js index 365393c..1e8b94a 100644 --- a/tests/plus-checkout-billing-tab-resolution.test.js +++ b/tests/plus-checkout-billing-tab-resolution.test.js @@ -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); diff --git a/项目完整链路说明.md b/项目完整链路说明.md index 8bc200c..ef4b7a0 100644 --- a/项目完整链路说明.md +++ b/项目完整链路说明.md @@ -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 步记录。 diff --git a/项目文件结构说明.md b/项目文件结构说明.md index 73c6cb2..331522d 100644 --- a/项目文件结构说明.md +++ b/项目文件结构说明.md @@ -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/`