From 0a8c7925b6d7725752fd36a6928c11a932d1fc5a Mon Sep 17 00:00:00 2001 From: CodingMan Date: Sat, 8 Aug 2026 20:30:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8A=8A=20PyPtt.TwoFactorAuthRequired?= =?UTF-8?q?=20=E6=A8=99=E6=88=90=20TWO=5FFACTOR=5FAUTH=5FREQUIRED=EF=BC=8C?= =?UTF-8?q?=E5=88=A5=E8=A2=AB=20LoginError=20=E5=90=83=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PyPtt 2.3.6 新增 TwoFactorAuthRequired,用於偵測到 PTT 兩階段驗證畫面。 它繼承 LoginError,而 _handle_ptt_exception 是依 EXCEPTION_MAPPING 的插入 順序做 isinstance 線性掃描,LoginError 會先命中,把「請先從這台機器手動 登入完成一次驗證」的指引訊息換成泛用的「登入失敗」。 - utils.py 在掃描迴圈之前加顯式分支。放在迴圈前而非塞進 dict,是因為 dict 順序是隱性契約,顯式分支的意圖不會被後人重排時弄壞 - message 直接透傳 str(e),不在本 repo 複製指引文字 — 那段字在 PyPtt 的 i18n 語言檔裡,複製會兩處不同步,也會失去語系切換 - 補 LoginError 仍回 LOGIN_FAILED 的回歸測試,避免反過來搶走父類 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017UbZgc9a53f3sLyAg3DNbF --- src/_version.py | 2 +- src/api_ptt.py | 1 + src/test_utils.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ src/utils.py | 14 ++++++++++++++ 4 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/_version.py b/src/_version.py index 493f741..260c070 100644 --- a/src/_version.py +++ b/src/_version.py @@ -1 +1 @@ -__version__ = "0.3.0" +__version__ = "0.3.1" diff --git a/src/api_ptt.py b/src/api_ptt.py index fa56133..bd74151 100644 --- a/src/api_ptt.py +++ b/src/api_ptt.py @@ -152,6 +152,7 @@ def login() -> Dict[str, Any]: - 'NO_SUCH_POST': 在看板中找不到文章 AID 或 Index。 - 'NO_PERMISSION': 沒有權限。 - 'LOGIN_FAILED': 登入失敗。 + - 'TWO_FACTOR_AUTH_REQUIRED': 需要二階段驗證,請先用一般 BBS 客戶端從這台機器手動登入完成驗證。 - 'WRONG_CREDENTIALS': 帳號或密碼錯誤。 - 'CANT_RESPONSE': 已結案並標記, 不得回應。 - 'NO_FAST_COMMENT': 推文間隔太短。 diff --git a/src/test_utils.py b/src/test_utils.py index 3b392ab..3f5b464 100644 --- a/src/test_utils.py +++ b/src/test_utils.py @@ -42,6 +42,51 @@ def test_parameter_error_maps_to_parameter_error_code(): sys.modules[m] = mod +def test_two_factor_auth_required_maps_to_dedicated_code(): + src_dir = os.path.dirname(os.path.abspath(__file__)) + if src_dir not in sys.path: + sys.path.insert(0, src_dir) + snapshot = {m: sys.modules.get(m) for m in _SHARED} + try: + if not getattr(sys.modules.get("PyPtt"), "__file__", None): + for m in _SHARED: + sys.modules.pop(m, None) + import PyPtt + + if not hasattr(PyPtt, "TwoFactorAuthRequired"): + pytest.skip("real PyPtt (>=2.3.6) not installed") + import utils + + # PyPtt 例外的 message 來自 i18n,建構前得先 init,否則 + # 存取 .message 會噴 AttributeError(PyPtt.API 平常會幫你做這步)。 + import PyPtt.data_type as pyptt_data_type + import PyPtt.i18n as pyptt_i18n + + pyptt_i18n.init(pyptt_data_type.Language.MANDARIN) + + # TwoFactorAuthRequired 繼承 LoginError,須確認沒被父類搶先攔截, + # 且保留 PyPtt 的原始訊息(不是被換成「登入失敗」)。 + exc = PyPtt.TwoFactorAuthRequired() + result = utils._handle_ptt_exception(exc, {}) + assert result["success"] is False + assert result["code"] == "TWO_FACTOR_AUTH_REQUIRED" + assert result["message"] == str(exc) + assert result["message"] != "登入失敗" + + # 回歸保護:LoginError 本身仍要維持 LOGIN_FAILED,不能被上面的分支搶走。 + login_error_result = utils._handle_ptt_exception(PyPtt.LoginError(), {}) + assert login_error_result["code"] == "LOGIN_FAILED" + assert login_error_result["message"] == "登入失敗" + finally: + for m in _SHARED: + mod = snapshot[m] + if mod is None: + sys.modules.pop(m, None) + else: + sys.modules[m] = mod + + if __name__ == "__main__": test_parameter_error_maps_to_parameter_error_code() + test_two_factor_auth_required_maps_to_dedicated_code() print("OK") diff --git a/src/utils.py b/src/utils.py index 964c517..033a870 100644 --- a/src/utils.py +++ b/src/utils.py @@ -29,6 +29,20 @@ def _handle_ptt_exception(e: Exception, kwargs: Dict[str, Any]) -> Dict[str, Any ), } + # TwoFactorAuthRequired 繼承 LoginError,若放進上面的 dict 迴圈會被 + # LoginError 先攔截、蓋成泛用的「登入失敗」。獨立分支、放在迴圈之前, + # 保留 PyPtt 的原始訊息(含如何完成 2FA 驗證的指引)。getattr 是為了 + # 相容裝了舊版 PyPtt(<2.3.6,還沒有這個例外類別)的環境。 + two_factor_auth_required = getattr(PyPtt, "TwoFactorAuthRequired", None) + if two_factor_auth_required is not None and isinstance( + e, two_factor_auth_required + ): + return { + "success": False, + "message": str(e), + "code": "TWO_FACTOR_AUTH_REQUIRED", + } + for exc_type, (message_format, code) in EXCEPTION_MAPPING.items(): if isinstance(e, exc_type): message = (