From 94482f7733b7b07bf025619d75dd4a09b152a0e8 Mon Sep 17 00:00:00 2001 From: CodingMan Date: Mon, 20 Jul 2026 21:34:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20del=5Fpost=20=E6=94=AF=E6=8F=B4?= =?UTF-8?q?=E5=8A=A3=E9=80=80=EF=BC=8F=E6=83=A1=E9=80=80=EF=BC=88bad=5Fpos?= =?UTF-8?q?t=5Ftype=20/=20bad=5Fpost=5Freason=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP 的 del_post 原本只轉發 board/aid/index,沒接 PyPtt 的劣退(badpost)參數。新增 bad_post_type(AD/BAD_LANGUAGE/PERSONAL_ATTACK/OTHER)與 bad_post_reason,並把字串名轉成 PyPtt.BadPostType(IntEnum,不能像 SearchType 直接傳字串);非法名稱回 BAD_PARAM 且不觸及 PyPtt。docstring 同時列「劣退」「惡退」兩種說法,使用者講哪個詞都能對到工具。 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api_ptt.py | 46 ++++++++++++++++++++++++++++++++++- src/test_del_post.py | 58 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 src/test_del_post.py diff --git a/src/api_ptt.py b/src/api_ptt.py index 95291ac..fa56133 100644 --- a/src/api_ptt.py +++ b/src/api_ptt.py @@ -10,6 +10,20 @@ _pool_lock = threading.Lock() +def _bad_post_type_from_name(name: Optional[str]) -> Optional["PyPtt.BadPostType"]: + """劣退/惡退分類的字串名 → PyPtt.BadPostType。 + + BadPostType 是 IntEnum(不像 SearchType 是 str 子類),MCP 收到的字串不能 + 直接傳給 PyPtt,得先用成員名查回 enum。未知名稱丟 ValueError 由呼叫端轉錯誤。 + """ + if name is None: + return None + try: + return PyPtt.BadPostType[name] + except KeyError: + raise ValueError(name) + + def _login_new_service( ptt_id: str, ptt_pw: str ) -> Tuple[Optional[Any], Dict[str, Any]]: @@ -400,7 +414,11 @@ def reply_post( @mcp.tool() def del_post( - board: str, aid: Optional[str] = None, index: int = 0 + board: str, + aid: Optional[str] = None, + index: int = 0, + bad_post_type: Optional[str] = None, + bad_post_reason: Optional[str] = None, ) -> Dict[str, Any]: """刪除文章。 @@ -409,22 +427,48 @@ def del_post( 註記:此函式必須先登入 PTT。 + 劣退/惡退(劣文退文,PTT 選單上顯示為「惡退」,兩種說法都是同一件事): + 板主刪除「他人」文章時,可對作者記一支退文(badpost)。 + 只有板主刪他人文章時才適用;刪自己的文章請勿傳 bad_post_type。 + Args: board (str): 文章所在的看板名稱。 aid (str, optional): 文章的 ID (AID)。與 `index` 擇一使用。 index (int, optional): 文章的索引,從 1 開始。與 `aid` 擇一使用。 + bad_post_type (str, optional): 劣退/惡退分類,四選一: + "AD"(廣告)、"BAD_LANGUAGE"(不當用辭)、 + "PERSONAL_ATTACK"(人身攻擊)、"OTHER"(其他)。 + 不傳=不記劣退/惡退,只單純刪文。 + bad_post_reason (str, optional): 劣退/惡退理由。僅當 bad_post_type 為 "OTHER" 時 + 必須傳入,且不可超過 50 bytes(Big5 編碼); + 其餘分類請勿傳入。 Returns: Dict[str, Any]: 一個包含操作結果的字典。 成功: {'success': True, 'message': '刪除成功'} 失敗: {'success': False, 'message': '...', 'code': '...'} + bad_post_type 無效: {'success': False, 'message': '...', + 'code': 'BAD_PARAM'} """ + try: + bpt = _bad_post_type_from_name(bad_post_type) + except ValueError: + return { + "success": False, + "message": ( + f"未知的 bad_post_type: {bad_post_type!r}," + "可用: AD, BAD_LANGUAGE, PERSONAL_ATTACK, OTHER" + ), + "code": "BAD_PARAM", + } return _call_ptt_service( memory_storage, "del_post", board=board, aid=aid, index=index, + bad_post_type=bpt, + bad_post_reason=bad_post_reason, success_message="刪除成功", ) diff --git a/src/test_del_post.py b/src/test_del_post.py new file mode 100644 index 0000000..7215eff --- /dev/null +++ b/src/test_del_post.py @@ -0,0 +1,58 @@ +import os +import sys + +import pytest + +# api_ptt / utils / PyPtt are shared via sys.modules. Sibling tests stub PyPtt +# (an empty ModuleType) so they can run without it installed, and monkeypatch +# api_ptt.PyPtt. This test needs the REAL PyPtt.BadPostType, so it briefly +# swaps in the real modules and restores the exact prior sys.modules state in a +# finally block — otherwise reloading api_ptt would leak into those tests. +_SHARED = ("PyPtt", "utils", "api_ptt") + + +def _load_real_api_ptt(): + src_dir = os.path.dirname(os.path.abspath(__file__)) + if src_dir not in sys.path: + sys.path.insert(0, src_dir) + # Evict any stub PyPtt (no __file__) and the modules that bound it. + if not getattr(sys.modules.get("PyPtt"), "__file__", None): + for m in _SHARED: + sys.modules.pop(m, None) + import PyPtt + + if not hasattr(PyPtt, "BadPostType"): + pytest.skip("real PyPtt not installed; helper needs PyPtt.BadPostType") + import api_ptt + + return api_ptt, PyPtt + + +def test_bad_post_type_from_name(): + snapshot = {m: sys.modules.get(m) for m in _SHARED} + try: + api_ptt, PyPtt = _load_real_api_ptt() + + # Every valid name maps to the same IntEnum member (MCP sends a string, + # PyPtt wants the enum). + for name in ("AD", "BAD_LANGUAGE", "PERSONAL_ATTACK", "OTHER"): + assert api_ptt._bad_post_type_from_name(name) is PyPtt.BadPostType[name] + + # None means "no bad-post", passes straight through. + assert api_ptt._bad_post_type_from_name(None) is None + + # Unknown name raises ValueError (del_post turns it into a BAD_PARAM error). + with pytest.raises(ValueError): + api_ptt._bad_post_type_from_name("NOPE") + 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_bad_post_type_from_name() + print("OK")