diff --git a/background.js b/background.js index 8b6e64d..14bb910 100644 --- a/background.js +++ b/background.js @@ -609,6 +609,11 @@ const PERSISTED_SETTING_DEFAULTS = { gopayHelperPhoneNumber: '', gopayHelperCountryCode: '+86', gopayHelperPin: '', + gopayHelperOtpChannel: 'whatsapp', + gopayHelperLocalSmsHelperEnabled: false, + gopayHelperLocalSmsHelperUrl: 'http://127.0.0.1:18767', + gopayHelperLocalSmsTimeoutSeconds: 90, + gopayHelperLocalSmsPollIntervalSeconds: 2, gopayHelperReferenceId: '', gopayHelperGoPayGuid: '', gopayHelperRedirectUrl: '', @@ -774,6 +779,7 @@ const DEFAULT_STATE = { gopayHelperFlowId: '', gopayHelperChallengeId: '', gopayHelperStartPayload: null, + gopayHelperOrderCreatedAt: 0, gopayHelperPinPayload: null, gopayHelperResolvedOtp: '', gopayHelperOtpRequestId: '', @@ -1015,6 +1021,38 @@ function normalizePhoneCodePollMaxRounds(value, fallback = DEFAULT_PHONE_CODE_PO ); } +function normalizeBoundedIntegerSetting(value, fallback, min, max) { + const rawValue = String(value ?? '').trim(); + const numeric = Number(rawValue); + const fallbackNumeric = Number(fallback); + const normalizedFallback = Number.isFinite(fallbackNumeric) + ? Math.min(max, Math.max(min, Math.floor(fallbackNumeric))) + : min; + if (!rawValue || !Number.isFinite(numeric)) { + return normalizedFallback; + } + return Math.min(max, Math.max(min, Math.floor(numeric))); +} + +function normalizeLocalHttpBaseUrl(value = '', fallback = 'http://127.0.0.1:18767') { + const rawValue = String(value || fallback).trim(); + try { + const parsed = new URL(rawValue); + if (!['http:', 'https:'].includes(parsed.protocol)) { + return fallback; + } + const endpointPath = parsed.pathname.replace(/\/+$/g, '') || '/'; + if (['/otp', '/latest-otp', '/health'].includes(endpointPath)) { + parsed.pathname = ''; + parsed.search = ''; + parsed.hash = ''; + } + return parsed.toString().replace(/\/$/, ''); + } catch { + return fallback; + } +} + function normalizeHeroSmsMaxPrice(value = '') { const rawValue = String(value ?? '').trim(); if (!rawValue) { @@ -2213,6 +2251,29 @@ function normalizePersistentSettingValue(key, value) { return self.GoPayUtils?.normalizeGoPayCountryCode ? self.GoPayUtils.normalizeGoPayCountryCode(value) : String(value || '+86').trim(); + case 'gopayHelperOtpChannel': + return self.GoPayUtils?.normalizeGpcOtpChannel + ? self.GoPayUtils.normalizeGpcOtpChannel(value) + : (String(value || '').trim().toLowerCase() === 'sms' ? 'sms' : 'whatsapp'); + case 'gopayHelperLocalSmsHelperUrl': + return normalizeLocalHttpBaseUrl( + value, + PERSISTED_SETTING_DEFAULTS.gopayHelperLocalSmsHelperUrl || 'http://127.0.0.1:18767' + ); + case 'gopayHelperLocalSmsTimeoutSeconds': + return normalizeBoundedIntegerSetting( + value, + PERSISTED_SETTING_DEFAULTS.gopayHelperLocalSmsTimeoutSeconds, + 10, + 300 + ); + case 'gopayHelperLocalSmsPollIntervalSeconds': + return normalizeBoundedIntegerSetting( + value, + PERSISTED_SETTING_DEFAULTS.gopayHelperLocalSmsPollIntervalSeconds, + 1, + 30 + ); case 'gopayHelperApiUrl': { const defaultGpcHelperApiUrl = PERSISTED_SETTING_DEFAULTS.gopayHelperApiUrl @@ -2238,6 +2299,7 @@ function normalizePersistentSettingValue(key, value) { return Math.max(0, Number(value) || 0); case 'autoRunSkipFailures': case 'oauthFlowTimeoutEnabled': + case 'gopayHelperLocalSmsHelperEnabled': case 'autoRunDelayEnabled': case 'phoneVerificationEnabled': case 'plusModeEnabled': @@ -7349,6 +7411,7 @@ function getDownstreamStateResets(step, state = {}) { gopayHelperFlowId: '', gopayHelperChallengeId: '', gopayHelperStartPayload: null, + gopayHelperOrderCreatedAt: 0, gopayHelperPinPayload: null, gopayHelperResolvedOtp: '', gopayHelperOtpRequestId: '', diff --git a/background/steps/create-plus-checkout.js b/background/steps/create-plus-checkout.js index 8e1b67b..4914884 100644 --- a/background/steps/create-plus-checkout.js +++ b/background/steps/create-plus-checkout.js @@ -11,7 +11,7 @@ function createPlusCheckoutCreateExecutor(deps = {}) { const { - addLog, + addLog: rawAddLog = async () => {}, chrome, completeStepFromBackground, ensureContentScriptReadyOnTabUntilStopped, @@ -25,6 +25,14 @@ throwIfStopped = () => {}, } = deps; + function addLog(message, level = 'info', options = {}) { + return rawAddLog(message, level, { + step: 6, + stepKey: 'plus-checkout-create', + ...(options && typeof options === 'object' ? options : {}), + }); + } + function normalizePlusPaymentMethod(value = '') { const rootScope = typeof self !== 'undefined' ? self : globalThis; if (rootScope.GoPayUtils?.normalizePlusPaymentMethod) { @@ -79,6 +87,14 @@ return cleaned; } + function normalizeGpcOtpChannel(value = '') { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.GoPayUtils?.normalizeGpcOtpChannel) { + return rootScope.GoPayUtils.normalizeGpcOtpChannel(value); + } + return String(value || '').trim().toLowerCase() === 'sms' ? 'sms' : 'whatsapp'; + } + function resolveGpcHelperCardKey(state = {}) { const cardKey = String(state?.gopayHelperCardKey || state?.gpcCardKey || state?.cardKey || '').trim(); if (!cardKey) { @@ -307,9 +323,12 @@ type: 'gopay', country_code: countryCode, phone_number: normalizeHelperPhoneNumber(phoneNumber, countryCode), + phone_mode: 'manual', + otp_channel: normalizeGpcOtpChannel(state?.gopayHelperOtpChannel), }, }; + const orderCreatedAt = Date.now(); const { response, data } = await fetchJsonWithTimeout(apiUrl, { method: 'POST', headers: { @@ -346,6 +365,7 @@ nextAction, flowId, challengeId, + orderCreatedAt, responsePayload: data && typeof data === 'object' && !Array.isArray(data) ? data : null, country: 'ID', currency: 'IDR', @@ -385,6 +405,7 @@ gopayHelperFlowId: result.flowId, gopayHelperChallengeId: result.challengeId, gopayHelperStartPayload: result.responsePayload, + gopayHelperOrderCreatedAt: result.orderCreatedAt || Date.now(), }); await addLog('步骤 6:GPC 订单已创建,准备继续下一步。', 'info'); await completeStepFromBackground(6, { diff --git a/background/steps/fill-plus-checkout.js b/background/steps/fill-plus-checkout.js index 692b2dd..89133ec 100644 --- a/background/steps/fill-plus-checkout.js +++ b/background/steps/fill-plus-checkout.js @@ -54,7 +54,7 @@ function createPlusCheckoutBillingExecutor(deps = {}) { const { - addLog, + addLog: rawAddLog = async () => {}, broadcastDataUpdate, chrome, completeStepFromBackground, @@ -73,6 +73,14 @@ throwIfStopped = () => {}, } = deps; + function addLog(message, level = 'info', options = {}) { + return rawAddLog(message, level, { + step: 7, + stepKey: 'plus-checkout-billing', + ...(options && typeof options === 'object' ? options : {}), + }); + } + function isPlusCheckoutUrl(url = '') { return PLUS_CHECKOUT_URL_PATTERN.test(String(url || '')); } @@ -238,6 +246,129 @@ return Promise.resolve({}); } + function normalizeLocalSmsHelperBaseUrl(value = '') { + const fallback = 'http://127.0.0.1:18767'; + const rawValue = String(value || fallback).trim(); + try { + const parsed = new URL(rawValue); + if (!['http:', 'https:'].includes(parsed.protocol)) { + return fallback; + } + const endpointPath = parsed.pathname.replace(/\/+$/g, '') || '/'; + if (['/otp', '/latest-otp', '/health'].includes(endpointPath)) { + parsed.pathname = ''; + parsed.search = ''; + parsed.hash = ''; + } + return parsed.toString().replace(/\/$/, ''); + } catch { + return fallback; + } + } + + function normalizeIncomingGpcSmsOtp(payload = {}) { + const candidates = [ + payload?.otp, + payload?.code, + payload?.sms_code, + payload?.smsCode, + payload?.verification_code, + payload?.verificationCode, + ]; + for (const candidate of candidates) { + const normalized = String(candidate || '').trim().replace(/[^\d]/g, ''); + if (/^\d{4,8}$/.test(normalized)) { + return normalized; + } + } + const messageText = String(payload?.message_text || payload?.messageText || payload?.text || '').trim(); + if (messageText) { + const match = messageText.match(/(?:OTP\s*[::]?\s*|#)(\d{4,8})\b|\b(\d{6})\b/i); + if (match) { + return String(match[1] || match[2] || '').trim(); + } + } + return ''; + } + + function normalizeGpcOtpChannel(value = '') { + const rootScope = typeof self !== 'undefined' ? self : globalThis; + if (rootScope.GoPayUtils?.normalizeGpcOtpChannel) { + return rootScope.GoPayUtils.normalizeGpcOtpChannel(value); + } + return String(value || '').trim().toLowerCase() === 'sms' ? 'sms' : 'whatsapp'; + } + + function normalizeEpochMilliseconds(value = 0) { + const rawValue = String(value ?? '').trim(); + if (!rawValue) { + return 0; + } + const numeric = Number(rawValue); + if (Number.isFinite(numeric) && numeric > 0) { + return Math.floor(numeric < 100000000000 ? numeric * 1000 : numeric); + } + const parsed = Date.parse(rawValue); + return Number.isFinite(parsed) ? parsed : 0; + } + + function buildLocalSmsHelperOtpUrl(state = {}, referenceId = '') { + const baseUrl = normalizeLocalSmsHelperBaseUrl(state?.gopayHelperLocalSmsHelperUrl); + const url = new URL(`${baseUrl}/otp`); + const normalizedReferenceId = String(referenceId || '').trim(); + const phoneNumber = String(state?.gopayHelperPhoneNumber || '').trim(); + const orderCreatedAt = normalizeEpochMilliseconds( + state?.gopayHelperOrderCreatedAt + || state?.gopayHelperStartPayload?.order_created_at + || state?.gopayHelperStartPayload?.orderCreatedAt + || state?.gopayHelperStartPayload?.created_at + || state?.gopayHelperStartPayload?.createdAt + ); + if (normalizedReferenceId) { + url.searchParams.set('reference_id', normalizedReferenceId); + } + if (phoneNumber) { + url.searchParams.set('phone_number', phoneNumber); + } + if (orderCreatedAt > 0) { + url.searchParams.set('after_ms', String(orderCreatedAt)); + } + return url.toString(); + } + + async function pollLocalSmsHelperOtp(state = {}, referenceId = '') { + const timeoutSeconds = Math.max(10, Math.min(300, Number(state?.gopayHelperLocalSmsTimeoutSeconds) || 90)); + const pollIntervalSeconds = Math.max(1, Math.min(30, Number(state?.gopayHelperLocalSmsPollIntervalSeconds) || 2)); + const deadline = Date.now() + timeoutSeconds * 1000; + const requestUrl = buildLocalSmsHelperOtpUrl(state, referenceId); + let lastMessage = ''; + while (Date.now() <= deadline) { + throwIfStopped(); + try { + const { response, data } = await fetchJsonWithTimeout(requestUrl, { + method: 'GET', + headers: { Accept: 'application/json' }, + }, Math.min(8000, Math.max(1000, pollIntervalSeconds * 1000))); + const otp = normalizeIncomingGpcSmsOtp(data || {}); + if (response?.ok && otp) { + await setState({ + gopayHelperResolvedOtp: otp, + gopayHelperSmsOtpPayload: data && typeof data === 'object' && !Array.isArray(data) ? data : null, + }); + if (typeof broadcastDataUpdate === 'function') { + broadcastDataUpdate({ gopayHelperResolvedOtp: otp }); + } + return otp; + } + lastMessage = String(data?.message || data?.status || '').trim(); + } catch (error) { + lastMessage = error?.message || String(error || '未知错误'); + } + await sleepWithStop(pollIntervalSeconds * 1000); + } + throw new Error(lastMessage || '本地 SMS Helper 等待 OTP 超时。'); + } + async function requestGpcOtpInput({ title = '', message = '', referenceId = '' }) { const requestId = `otp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const payload = { @@ -291,12 +422,26 @@ throw new Error('步骤 7:GPC 模式缺少卡密。'); } await addLog(`步骤 7:GPC 模式开始 OTP 验证(reference_id: ${referenceId})...`, 'info'); - await addLog('步骤 7:等待用户输入 OTP...', 'info'); - const otp = await requestGpcOtpInput({ - title: 'GPC OTP 验证', - message: `请输入收到的 OTP 验证码(reference_id: ${referenceId})`, - referenceId, - }); + let otp = ''; + const useLocalSmsHelper = Boolean(state?.gopayHelperLocalSmsHelperEnabled) + && normalizeGpcOtpChannel(state?.gopayHelperOtpChannel) === 'sms'; + if (useLocalSmsHelper) { + try { + await addLog('步骤 7:正在从本地 SMS Helper 等待 GPC OTP...', 'info'); + otp = await pollLocalSmsHelperOtp(state, referenceId); + await addLog('步骤 7:本地 SMS Helper 已读取到 GPC OTP,准备提交验证。', 'ok'); + } catch (error) { + await addLog(`步骤 7:本地 SMS Helper 未能自动读取 OTP:${error?.message || String(error || '未知错误')},改为手动输入。`, 'warn'); + } + } + if (!otp) { + await addLog('步骤 7:等待用户输入 OTP...', 'info'); + otp = await requestGpcOtpInput({ + title: 'GPC OTP 验证', + message: `请输入收到的 OTP 验证码(reference_id: ${referenceId})`, + referenceId, + }); + } const flowId = state?.gopayHelperFlowId || state?.gopayHelperStartPayload?.flow_id diff --git a/gopay-utils.js b/gopay-utils.js index 462f034..53eb4c1 100644 --- a/gopay-utils.js +++ b/gopay-utils.js @@ -46,6 +46,14 @@ return String(value || '').trim().replace(/[^\d]/g, ''); } + function normalizeGpcOtpChannel(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'sms') { + return 'sms'; + } + return 'whatsapp'; + } + function normalizeGpcHelperBaseUrl(apiUrl = '') { let normalized = String(apiUrl || DEFAULT_GPC_HELPER_API_URL).trim(); if (!normalized) { @@ -213,6 +221,7 @@ normalizeGoPayPhoneForCountry, normalizeGoPayOtp, normalizeGoPayPin, + normalizeGpcOtpChannel, normalizePlusPaymentMethod, }; }); diff --git a/scripts/gpc_sms_helper_macos.py b/scripts/gpc_sms_helper_macos.py new file mode 100644 index 0000000..65092b9 --- /dev/null +++ b/scripts/gpc_sms_helper_macos.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +"""Local macOS SMS OTP helper for GPC GoPay flows. + +Run this on the Mac that receives forwarded iPhone SMS messages. The helper +reads a copy of the macOS Messages database and exposes the latest matching OTP +on a localhost HTTP endpoint for the Chrome extension. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import platform +import re +import shutil +import sqlite3 +import sys +import tempfile +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Optional +from urllib.parse import parse_qs, urlparse + +HOST = "127.0.0.1" +PORT = 18767 +DEFAULT_DB = "~/Library/Messages/chat.db" +MAC_ABSOLUTE_EPOCH = dt.datetime(2001, 1, 1, tzinfo=dt.timezone.utc) + +OTP_PATTERNS = ( + re.compile(r"(?i)\bOTP\s*[::]?\s*([0-9]{4,8})\b"), + re.compile(r"#([0-9]{4,8})\b"), + re.compile(r"(? bool: + override = os.environ.get("GPC_SMS_HELPER_ALLOW_NON_MAC", "").strip().lower() + return platform.system() == "Darwin" or override in {"1", "true", "yes"} + + +def require_macos() -> None: + if not is_macos(): + raise RuntimeError( + "GPC 本地 SMS Helper 仅支持 macOS:需要读取 ~/Library/Messages/chat.db。" + "请确认接收验证码的 iPhone 已开启短信转发,并能在 Mac 信息 app 中看到短信。" + ) + + +def extract_gopay_otp(text: str, require_keywords: bool = True) -> Optional[str]: + raw = text or "" + lowered = raw.lower() + if require_keywords and not any(keyword in lowered for keyword in KEYWORDS): + return None + for pattern in OTP_PATTERNS: + match = pattern.search(raw) + if match: + return match.group(1) + return None + + +def mac_message_time_to_datetime(value: int | float | None) -> dt.datetime: + if not value: + return dt.datetime.now(dt.timezone.utc) + seconds = float(value) + if seconds > 10_000_000_000: + seconds = seconds / 1_000_000_000 + return MAC_ABSOLUTE_EPOCH + dt.timedelta(seconds=seconds) + + +def update_state(**updates: object) -> None: + with STATE_LOCK: + STATE.update(updates) + + +def get_state() -> dict: + with STATE_LOCK: + return dict(STATE) + + +def copy_messages_db(db_path: Path) -> Path: + if not db_path.exists(): + raise FileNotFoundError(f"Messages 数据库不存在:{db_path}") + tmpdir = Path(tempfile.mkdtemp(prefix="gpc_messages_")) + copied = tmpdir / "chat.db" + shutil.copy2(db_path, copied) + wal = db_path.with_name(db_path.name + "-wal") + shm = db_path.with_name(db_path.name + "-shm") + if wal.exists(): + shutil.copy2(wal, copied.with_name("chat.db-wal")) + if shm.exists(): + shutil.copy2(shm, copied.with_name("chat.db-shm")) + return copied + + +def read_recent_messages(db_path: Path, after_rowid: int = 0, limit: int = 80) -> list[dict]: + copied = copy_messages_db(db_path) + try: + conn = sqlite3.connect(str(copied)) + conn.row_factory = sqlite3.Row + rows = conn.execute( + """ + SELECT + message.ROWID AS rowid, + message.guid AS guid, + message.text AS text, + message.date AS date, + message.service AS service, + handle.id AS handle + FROM message + LEFT JOIN handle ON message.handle_id = handle.ROWID + WHERE message.ROWID > ? + AND message.text IS NOT NULL + ORDER BY message.ROWID DESC + LIMIT ? + """, + (max(0, int(after_rowid or 0)), max(1, int(limit or 80))), + ).fetchall() + return [dict(row) for row in rows] + finally: + try: + conn.close() # type: ignore[name-defined] + except Exception: + pass + shutil.rmtree(copied.parent, ignore_errors=True) + + +def make_otp_record(row: dict, otp: str) -> dict: + received_at = mac_message_time_to_datetime(row.get("date")).isoformat() + return { + "otp": otp, + "code": otp, + "message_id": str(row.get("guid") or row.get("rowid") or ""), + "rowid": int(row.get("rowid") or 0), + "sender": str(row.get("handle") or ""), + "service": str(row.get("service") or ""), + "received_at": received_at, + "message_text": str(row.get("text") or ""), + } + + +def append_otp(record: dict, max_records: int = 30) -> None: + with STATE_LOCK: + records = [item for item in STATE.get("otps", []) if item.get("message_id") != record.get("message_id")] + records.insert(0, record) + STATE["otps"] = records[:max_records] + STATE["last_otp"] = record + STATE["last_rowid"] = max(int(STATE.get("last_rowid") or 0), int(record.get("rowid") or 0)) + + +def parse_timestamp_ms(value: object) -> int: + if value is None: + return 0 + if isinstance(value, (int, float)): + numeric = float(value) + else: + raw = str(value).strip() + if not raw: + return 0 + try: + numeric = float(raw) + except ValueError: + try: + parsed = dt.datetime.fromisoformat(raw.replace("Z", "+00:00")) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=dt.timezone.utc) + return int(parsed.timestamp() * 1000) + except ValueError: + return 0 + if numeric <= 0: + return 0 + if numeric < 100_000_000_000: + numeric *= 1000 + return int(numeric) + + +def select_otp_record(state: dict, after_ms: int = 0) -> Optional[dict]: + records = state.get("otps") + if not isinstance(records, list): + records = [] + if after_ms > 0: + for record in records: + if isinstance(record, dict) and parse_timestamp_ms(record.get("received_at")) >= after_ms: + return record + return None + record = state.get("last_otp") or None + if isinstance(record, dict): + return record + return records[0] if records and isinstance(records[0], dict) else None + + +def scan_once(db_path: Path, require_keywords: bool = True) -> None: + state = get_state() + after_rowid = int(state.get("last_rowid") or 0) + rows = read_recent_messages(db_path, after_rowid=after_rowid) + max_rowid = after_rowid + for row in reversed(rows): + max_rowid = max(max_rowid, int(row.get("rowid") or 0)) + otp = extract_gopay_otp(row.get("text") or "", require_keywords=require_keywords) + if otp: + record = make_otp_record(row, otp) + append_otp(record) + print(f"captured OTP {otp} from message {record['message_id']} at {record['received_at']}", flush=True) + update_state(last_rowid=max_rowid, last_scan_at=dt.datetime.now(dt.timezone.utc).isoformat(), last_error="") + + +def scan_loop(db_path: Path, interval_seconds: float, require_keywords: bool) -> None: + update_state(db_path=str(db_path)) + while True: + try: + scan_once(db_path, require_keywords=require_keywords) + except Exception as exc: + update_state(last_error=str(exc), last_scan_at=dt.datetime.now(dt.timezone.utc).isoformat()) + print(f"scan error: {exc}", file=sys.stderr, flush=True) + time.sleep(max(0.5, float(interval_seconds or 2))) + + +def write_json(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + handler.send_response(status) + handler.send_header("Content-Type", "application/json; charset=utf-8") + handler.send_header("Access-Control-Allow-Origin", "*") + handler.send_header("Cache-Control", "no-store") + handler.send_header("Content-Length", str(len(body))) + handler.end_headers() + handler.wfile.write(body) + + +class HelperHandler(BaseHTTPRequestHandler): + server_version = "GpcSmsHelper/1.0" + + def log_message(self, fmt: str, *args: object) -> None: + print(f"{self.address_string()} - {fmt % args}", flush=True) + + def do_GET(self) -> None: # noqa: N802 + parsed = urlparse(self.path) + if parsed.path == "/health": + state = get_state() + write_json(self, 200, { + "ok": True, + "status": "ok", + "has_otp": bool(state.get("last_otp")), + "last_scan_at": state.get("last_scan_at"), + "last_error": state.get("last_error"), + }) + return + if parsed.path in ("/otp", "/latest-otp"): + query = parse_qs(parsed.query) + consume = str(query.get("consume", ["0"])[0]).strip().lower() in {"1", "true", "yes"} + after_ms = parse_timestamp_ms(query.get("after_ms", query.get("after", ["0"]))[0]) + state = get_state() + record = select_otp_record(state, after_ms=after_ms) + if not record: + write_json(self, 200, {"ok": True, "otp": "", "code": "", "status": "waiting", "message": "未查询到验证码"}) + return + payload = {"ok": True, "status": "found", **record} + if consume: + update_state(last_otp=None) + write_json(self, 200, payload) + return + write_json(self, 404, {"ok": False, "error": "not_found"}) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run a local macOS Messages HTTP helper for GPC SMS OTP.") + parser.add_argument("--host", default=HOST, help="Bind host, default 127.0.0.1") + parser.add_argument("--port", type=int, default=PORT, help="Bind port, default 18767") + parser.add_argument("--db", default=DEFAULT_DB, help="Path to macOS Messages chat.db") + parser.add_argument("--interval", type=float, default=2.0, help="Message scan interval in seconds") + parser.add_argument("--no-keywords", action="store_true", help="Accept any numeric OTP without GPC/OpenAI keywords") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + require_macos() + db_path = Path(os.path.expanduser(args.db)).resolve() + scanner = threading.Thread( + target=scan_loop, + args=(db_path, args.interval, not args.no_keywords), + daemon=True, + ) + scanner.start() + server = ThreadingHTTPServer((args.host, int(args.port)), HelperHandler) + print(f"GPC SMS Helper listening on http://{args.host}:{int(args.port)}", flush=True) + print("请确认 iPhone 短信已转发到本机 Messages。", flush=True) + server.serve_forever() + return 0 + except KeyboardInterrupt: + return 0 + except Exception as exc: + print(str(exc), file=sys.stderr, flush=True) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index a80d989..1584dd9 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -315,6 +315,28 @@ + + +