{"repo":"NousResearch/hermes-agent","public_id":"11465","display_label":"Feishu document comment intelligent reply feature","summary":"These members all relate to adding intelligent (AI-driven) reply functionality for Feishu/Lark document comments, incorporating a 3-tier access control system. The items appear to be a feature request and associated implementation pull requests for the same capability.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":11898,"merged_at":"2026-04-18T02:04:11+00:00"},"resolution":{"state":"resolved","by":"pr-11898"},"freshness":{"state":"stale","last_verified_at":"2026-04-18T02:04:11+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":1.0},"fix_edges":[{"issue_number":11465,"pr_number":11898,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-18T02:04:11+00:00"}],"members":[{"kind":"issue","number":11465,"title":"[Feature]: Support Feishu/Lark document comment intelligent reply","url":null,"state":"closed"},{"kind":"pr","number":11023,"title":"feat: Feishu document comment intelligent reply with 3-tier access control","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#11465","number":11465}],"category":"merged_via","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":11898,"title":"feat(feishu): intelligent reply on document comments with 3-tier access control","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/feishu.py","gateway/platforms/feishu_comment.py","gateway/platforms/feishu_comment_rules.py","scripts/release.py","tests/gateway/test_feishu.py","tests/gateway/test_feishu_comment.py","tests/gateway/test_feishu_comment_rules.py","tests/tools/test_feishu_tools.py","tools/feishu_doc_tool.py","tools/feishu_drive_tool.py","toolsets.py"],"fix_hunks":[{"file":"gateway/platforms/feishu.py","hunks":["@@ -1228,6 +1228,10 @@ def _build_event_handler(self) -> Any:\n             .register_p2_im_chat_member_bot_deleted_v1(self._on_bot_removed_from_chat)\n             .register_p2_im_chat_access_event_bot_p2p_chat_entered_v1(self._on_p2p_chat_entered)\n             .register_p2_im_message_recalled_v1(self._on_message_recalled)\n+            .register_p2_customized_event(\n+                \"drive.notice.comment_add_v1\",\n+                self._on_drive_comment_event,\n+            )\n             .build()\n         )\n ","@@ -1965,6 +1969,25 @@ def _on_p2p_chat_entered(self, data: Any) -> None:\n     def _on_message_recalled(self, data: Any) -> None:\n         logger.debug(\"[Feishu] Message recalled by user\")\n \n+    def _on_drive_comment_event(self, data: Any) -> None:\n+        \"\"\"Handle drive document comment notification (drive.notice.comment_add_v1).\n+\n+        Delegates to :mod:`gateway.platforms.feishu_comment` for parsing,\n+        logging, and reaction.  Scheduling follows the same\n+        ``run_coroutine_threadsafe`` pattern used by ``_on_message_event``.\n+        \"\"\"\n+        from gateway.platforms.feishu_comment import handle_drive_comment_event\n+\n+        loop = self._loop\n+        if not self._loop_accepts_callbacks(loop):\n+            logger.warning(\"[Feishu] Dropping drive comment event before adapter loop is ready\")\n+            return\n+        future = asyncio.run_coroutine_threadsafe(\n+            handle_drive_comment_event(self._client, data, self_open_id=self._bot_open_id),\n+            loop,\n+        )\n+        future.add_done_callback(self._log_background_failure)\n+\n     def _on_reaction_event(self, event_type: str, data: Any) -> None:\n         \"\"\"Route user reactions on bot messages as synthetic text events.\"\"\"\n         event = getattr(data, \"event\", None)","@@ -2590,6 +2613,8 @@ async def _handle_webhook_request(self, request: Any) -> Any:\n             self._on_reaction_event(event_type, data)\n         elif event_type == \"card.action.trigger\":\n             self._on_card_action_trigger(data)\n+        elif event_type == \"drive.notice.comment_add_v1\":\n+            self._on_drive_comment_event(data)\n         else:\n             logger.debug(\"[Feishu] Ignoring webhook event type: %s\", event_type or \"unknown\")\n         return web.json_response({\"code\": 0, \"msg\": \"ok\"})"]},{"file":"gateway/platforms/feishu_comment.py","hunks":["@@ -0,0 +1,1383 @@\n+\"\"\"\n+Feishu/Lark drive document comment handling.\n+\n+Processes ``drive.notice.comment_add_v1`` events and interacts with the\n+Drive v2 comment reaction API.  Kept in a separate module so that the\n+main ``feishu.py`` adapter does not grow further and comment-related\n+logic can evolve independently.\n+\n+Flow:\n+  1. Parse event -> extract file_token, comment_id, reply_id, etc.\n+  2. Add OK reaction\n+  3. Parallel fetch: doc meta + comment details (batch_query)\n+  4. Branch on is_whole:\n+       Whole -> list whole comments timeline\n+       Local -> list comment thread replies\n+  5. Build prompt (local or whole)\n+  6. Create AIAgent with feishu_doc + feishu_drive tools -> agent generates reply\n+  7. Route reply:\n+       Whole -> add_whole_comment\n+       Local -> reply_to_comment (fallback to add_whole_comment on 1069302)\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import asyncio\n+import json\n+import logging\n+from typing import Any, Dict, List, Optional, Tuple\n+\n+logger = logging.getLogger(__name__)\n+\n+# ---------------------------------------------------------------------------\n+# Lark SDK helpers (lazy-imported)\n+# ---------------------------------------------------------------------------\n+\n+\n+def _build_request(method: str, uri: str, paths=None, queries=None, body=None):\n+    \"\"\"Build a lark_oapi BaseRequest.\"\"\"\n+    from lark_oapi import AccessTokenType\n+    from lark_oapi.core.enum import HttpMethod\n+    from lark_oapi.core.model.base_request import BaseRequest\n+\n+    http_method = HttpMethod.GET if method == \"GET\" else HttpMethod.POST\n+\n+    builder = (\n+        BaseRequest.builder()\n+        .http_method(http_method)\n+        .uri(uri)\n+        .token_types({AccessTokenType.TENANT})\n+    )\n+    if paths:\n+        builder = builder.paths(paths)\n+    if queries:\n+        builder = builder.queries(queries)\n+    if body is not None:\n+        builder = builder.body(body)\n+    return builder.build()\n+\n+\n+async def _exec_request(client, method, uri, paths=None, queries=None, body=None):\n+    \"\"\"Execute a lark API request and return (code, msg, data_dict).\"\"\"\n+    logger.info(\"[Feishu-Comment] API >>> %s %s paths=%s queries=%s body=%s\",\n+                 method, uri, paths, queries,\n+                 json.dumps(body, ensure_ascii=False)[:500] if body else None)\n+    request = _build_request(method, uri, paths, queries, body)\n+    response = await asyncio.to_thread(client.request, request)\n+\n+    code = getattr(response, \"code\", None)\n+    msg = getattr(response, \"msg\", \"\")\n+\n+    data: dict = {}\n+    raw = getattr(response, \"raw\", None)\n+    if raw and hasattr(raw, \"content\"):\n+        try:\n+            body_json = json.loads(raw.content)\n+            data = body_json.get(\"data\", {})\n+        except (json.JSONDecodeError, AttributeError):\n+            pass\n+    if not data:\n+        resp_data = getattr(response, \"data\", None)\n+        if isinstance(resp_data, dict):\n+            data = resp_data\n+        elif resp_data and hasattr(resp_data, \"__dict__\"):\n+            data = vars(resp_data)\n+\n+    logger.info(\"[Feishu-Comment] API <<< %s %s code=%s msg=%s data_keys=%s\",\n+                 method, uri, code, msg, list(data.keys()) if data else \"empty\")\n+    if code != 0:\n+        # Log raw response for debugging failed API calls\n+        raw = getattr(response, \"raw\", None)\n+        raw_content = \"\"\n+        if raw and hasattr(raw, \"content\"):\n+            raw_content = raw.content[:500] if isinstance(raw.content, (str, bytes)) else str(raw.content)[:500]\n+        logger.warning(\"[Feishu-Comment] API FAIL raw response: %s\", raw_content)\n+    return code, msg, data\n+\n+\n+# ---------------------------------------------------------------------------\n+# Event parsing\n+# ---------------------------------------------------------------------------\n+\n+\n+def parse_drive_comment_event(data: Any) -> Optional[Dict[str, Any]]:\n+    \"\"\"Extract structured fields from a ``drive.notice.comment_add_v1`` payload.\n+\n+    *data* may be a ``CustomizedEvent`` (WebSocket) whose ``.event`` is a dict,\n+    or a ``SimpleNamespace`` (Webhook) built from the full JSON body.\n+\n+    Returns a flat dict with the relevant fields, or ``None`` when the\n+    payload is malformed.\n+    \"\"\"\n+    logger.debug(\"[Feishu-Comment] parse_drive_comment_event: data type=%s\", type(data).__name__)\n+    event = getattr(data, \"event\", None)\n+    if event is None:\n+        logger.debug(\"[Feishu-Comment] parse_drive_comment_event: no .event attribute, returning None\")\n+        return None\n+\n+    evt: dict = event if isinstance(event, dict) else (\n+        vars(event) if hasattr(event, \"__dict__\") else {}\n+    )\n+    logger.debug(\"[Feishu-Comment] parse_drive_comment_event: evt keys=%s\", list(evt.keys()))\n+\n+    notice_meta = evt.get(\"notice_meta\") or {}\n+    if not isinstance(notice_meta, dict):\n+        notice_meta = vars(notice_meta) if hasattr(notice_meta, \"__dict__\") else {}\n+\n+    from_user = notice_meta.get(\"from_user_id\") or {}\n+    if not isinstance(from_user, dict):\n+        from_user = vars(from_user) if hasattr(from_user, \"__dict__\") else {}\n+\n+    to_user = notice_meta.get(\"to_user_id\") or {}\n+    if not isinstance(to_user, dict):\n+        to_user = vars(to_user) if hasattr(to_user, \"__dict__\") else {}\n+\n+    return {\n+        \"event_id\": str(evt.get(\"event_id\") or \"\"),\n+        \"comment_id\": str(evt.get(\"comment_id\") or \"\"),\n+        \"reply_id\": str(evt.get(\"reply_id\") or \"\"),\n+        \"is_mentioned\": bool(evt.get(\"is_mentioned\")),\n+        \"timestamp\": str(evt.get(\"timestamp\") or \"\"),\n+        \"file_token\": [redacted:secret-assignment]\"file_token\") or \"\"),\n+        \"file_type\": str(notice_meta.get(\"file_type\") or \"\"),\n+        \"notice_type\": str(notice_meta.get(\"notice_type\") or \"\"),\n+        \"from_open_id\": str(from_user.get(\"open_id\") or \"\"),\n+        \"to_open_id\": str(to_user.get(\"open_id\") or \"\"),\n+    }\n+\n+\n+# ---------------------------------------------------------------------------\n+# Comment reaction API\n+# ---------------------------------------------------------------------------\n+\n+_REACTION_URI = \"/open-apis/drive/v2/files/:file_token/comments/reaction\"\n+\n+\n+async def add_comment_reaction(\n+    client: Any,\n+    *,\n+    file_token: str,\n+    file_type: str,\n+    reply_id: str,\n+    reaction_type: str = \"OK\",\n+) -> bool:\n+    \"\"\"Add an emoji reaction to a document comment reply.\n+\n+    Uses the Drive v2 ``update_reaction`` endpoint::\n+\n+        POST /open-apis/drive/v2/files/{file_token}/comments/reaction?file_type=...\n+\n+    Returns ``True`` on success, ``False`` on failure (errors are logged).\n+    \"\"\"\n+    try:\n+        from lark_oapi import AccessTokenType  # noqa: F401\n+    except ImportError:\n+        logger.error(\"[Feishu-Comment] lark_oapi not available\")\n+        return False\n+\n+    body = {\n+        \"action\": \"add\",\n+        \"reply_id\": reply_id,\n+        \"reaction_type\": reaction_type,\n+    }\n+\n+    code, msg, _ = await _exec_request(\n+        client, \"POST\", _REACTION_URI,\n+        paths={\"file_token\": [redacted:secret-assignment]\n+        queries=[(\"file_type\", file_type)],\n+        body=body,\n+    )\n+\n+    succeeded = code == 0\n+    if succeeded:\n+        logger.info(\n+            \"[Feishu-Comment] Reaction '%s' added: file=%s:%s reply=%s\",\n+            reaction_type, file_type, file_token, reply_id,\n+        )\n+    else:\n+        logger.warning(\n+            \"[Feishu-Comment] Reaction API failed: code=%s msg=%s \"\n+            \"file=%s:%s reply=%s\",\n+            code, msg, file_type, file_token, reply_id,\n+        )\n+    return succeeded\n+\n+\n+async def delete_comment_reaction(\n+    client: Any,\n+    *,\n+    file_token: str,\n+    file_type: str,\n+    reply_id: str,\n+    reaction_type: str = \"OK\",\n+) -> bool:\n+    \"\"\"Remove an emoji reaction from a document comment reply.\n+\n+    Best-effort — errors are logged but not raised.\n+    \"\"\"\n+    body = {\n+        \"action\": \"delete\",\n+        \"reply_id\": reply_id,\n+        \"reaction_type\": reaction_type,\n+    }\n+\n+    code, msg, _ = await _exec_request(\n+        client, \"POST\", _REACTION_URI,\n+        paths={\"file_token\": [redacted:secret-assignment]\n+        queries=[(\"file_type\", file_type)],\n+        body=body,\n+    )\n+\n+    succeeded = code == 0\n+    if succeeded:\n+        logger.info(\n+            \"[Feishu-Comment] Reaction '%s' deleted: file=%s:%s reply=%s\",\n+            reaction_type, file_type, file_token, reply_id,\n+        )\n+    else:\n+        logger.warning(\n+            \"[Feishu-Comment] Reaction API failed: code=%s msg=%s \"\n+            \"file=%s:%s reply=%s\",\n+            code, msg, file_type, file_token, reply_id,\n+        )\n+    return succeeded\n+\n+\n+# ---------------------------------------------------------------------------\n+# API call layer\n+# ---------------------------------------------------------------------------\n+\n+_BATCH_QUERY_META_URI = \"/open-apis/drive/v1/metas/batch_query\"\n+_BATCH_QUERY_COMMENT_URI = \"/open-apis/drive/v1/files/:file_token/comments/batch_query\"\n+_LIST_COMMENTS_URI = \"/open-apis/drive/v1/files/:file_token/comments\"\n+_LIST_REPLIES_URI = \"/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies\"\n+_REPLY_COMMENT_URI = \"/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies\"\n+_ADD_COMMENT_URI = \"/open-apis/drive/v1/files/:file_token/new_comments\"\n+\n+\n+async def query_document_meta(\n+    client: Any, file_token: str, file_type: str,\n+) -> Dict[str, Any]:\n+    \"\"\"Fetch document title and URL via batch_query meta API.\n+\n+    Returns ``{\"title\": \"...\", \"url\": \"...\", \"doc_type\": \"...\"}`` or empty dict.\n+    \"\"\"\n+    body = {\n+        \"request_docs\": [{\"doc_token\": [redacted:secret-assignment] \"doc_type\": file_type}],\n+        \"with_url\": True,\n+    }\n+    logger.debug(\"[Feishu-Comment] query_document_meta: file_token=%s file_type=%s\", file_token, file_type)\n+    code, msg, data = await _exec_request(\n+        client, \"POST\", _BATCH_QUERY_META_URI, body=body,\n+    )\n+    if code != 0:\n+        logger.warning(\"[Feishu-Comment] Meta batch_query failed: code=%s msg=%s\", code, msg)\n+        return {}\n+\n+    metas = data.get(\"metas\", [])\n+    logger.debug(\"[Feishu-Comment] query_document_meta: raw metas type=%s value=%s\",\n+                 type(metas).__name__, str(metas)[:300])\n+    if not metas:\n+        # Try alternate response shape: metas may be a dict keyed by token\n+        if isinstance(data.get(\"metas\"), dict):\n+            meta = data[\"metas\"].get(file_token, {})\n+        else:\n+            logger.debug(\"[Feishu-Comment] query_document_meta: no metas found\")\n+            return {}\n+    else:\n+        meta = metas[0] if isinstance(metas, list) else {}\n+\n+    result = {\n+        \"title\": meta.get(\"title\", \"\"),\n+        \"url\": meta.get(\"url\", \"\"),\n+        \"doc_type\": meta.get(\"doc_type\", file_type),\n+    }\n+    logger.info(\"[Feishu-Comment] query_document_meta: title=%s url=%s\",\n+                result[\"title\"], result[\"url\"][:80] if result[\"url\"] else \"\")\n+    return result\n+\n+\n+_COMMENT_RETRY_LIMIT = 6\n+_COMMENT_RETRY_DELAY_S = 1.0\n+\n+\n+async def batch_query_comment(\n+    client: Any, file_token: str, file_type: str, comment_id: str,\n+) -> Dict[str, Any]:\n+    \"\"\"Fetch comment details via batch_query comment API.\n+\n+    Retries up to 6 times on failure (handles eventual consistency).\n+\n+    Returns the comment dict with fields like ``is_whole``, ``quote``,\n+    ``reply_list``, etc.  Empty dict on failure.\n+    \"\"\"\n+    logger.debug(\"[Feishu-Comment] batch_query_comment: file_token=%s comment_id=%s\", file_token, comment_id)\n+\n+    for attempt in range(_COMMENT_RETRY_LIMIT):\n+        code, msg, data = await _exec_request(\n+            client, \"POST\", _BATCH_QUERY_COMMENT_URI,\n+            paths={\"file_token\": [redacted:secret-assignment]\n+            queries=[\n+                (\"file_type\", file_type),\n+                (\"user_id_type\", \"open_id\"),\n+            ],\n+            body={\"comment_ids\": [comment_id]},\n+        )\n+        if code == 0:\n+            break\n+        if attempt < _COMMENT_RETRY_LIMIT - 1:\n+            logger.info(\n+                \"[Feishu-Comment] batch_query_comment retry %d/%d: code=%s msg=%s\",\n+                attempt + 1, _COMMENT_RETRY_LIMIT, code, msg,\n+            )\n+            await asyncio.sleep(_COMMENT_RETRY_DELAY_S)\n+        else:\n+            logger.warning(\n+                \"[Feishu-Comment] batch_query_comment failed after %d attempts: code=%s msg=%s\",\n+                _COMMENT_RETRY_LIMIT, code, msg,\n+            )\n+            return {}\n+\n+    # Response: {\"items\": [{\"comment_id\": \"...\", ...}]}\n+    items = data.get(\"items\", [])\n+    logger.debug(\"[Feishu-Comment] batch_query_comment: got %d items\", len(items) if isinstance(items, list) else 0)\n+    if items and isinstance(items, list):\n+        item = items[0]\n+        logger.info(\"[Feishu-Comment] batch_query_comment: is_whole=%s quote=%s reply_count=%s\",\n+                    item.get(\"is_whole\"),\n+                    (item.get(\"quote\", \"\") or \"\")[:60],\n+                    len(item.get(\"reply_list\", {}).get(\"replies\", [])) if isinstance(item.get(\"reply_list\"), dict) else \"?\")\n+        return item\n+    logger.warning(\"[Feishu-Comment] batch_query_comment: empty items, raw data keys=%s\", list(data.keys()))\n+    return {}\n+\n+\n+async def list_whole_comments(\n+    client: Any, file_token: str, file_type: str,\n+) -> List[Dict[str, Any]]:\n+    \"\"\"List all whole-document comments (paginated, up to 500).\"\"\"\n+    logger.debug(\"[Feishu-Comment] list_whole_comments: file_token=%s\", file_token)\n+    all_comments: List[Dict[str, Any]] = []\n+    page_token = \"\"\n+\n+    for _ in range(5):  # max 5 pages\n+        queries = [\n+            (\"file_type\", file_type),\n+            (\"is_whole\", \"true\"),\n+            (\"page_size\", \"100\"),\n+            (\"user_id_type\", \"open_id\"),\n+        ]\n+        if page_token:\n+            queries.append((\"page_token\", page_token))\n+\n+        code, msg, data = await _exec_request(\n+            client, \"GET\", _LIST_COMMENTS_URI,\n+            paths={\"file_token\": [redacted:secret-assignment]\n+            queries=queries,\n+        )\n+        if code != 0:\n+            logger.warning(\"[Feishu-Comment] List whole comments failed: code=%s msg=%s\", code, msg)\n+            break\n+\n+        items = data.get(\"items\", [])\n+        if isinstance(items, list):\n+            all_comments.extend(items)\n+            logger.debug(\"[Feishu-Comment] list_whole_comments: page got %d items, total=%d\",\n+                         len(items), len(all_comments))\n+\n+        if not data.get(\"has_more\"):\n+            break\n+        page_token = data.get(\"page_token\", \"\")\n+        if not page_token:\n+            break\n+\n+    logger.info(\"[Feishu-Comment] list_whole_comments: total %d whole comments fetched\", len(all_comments))\n+    return all_comments\n+\n+\n+async def list_comment_replies(\n+    client: Any, file_token: str, file_type: str, comment_id: str,\n+    *, expect_reply_id: str = \"\",\n+) -> List[Dict[str, Any]]:\n+    \"\"\"List all replies in a comment thread (paginated, up to 500).\n+\n+    If *expect_reply_id* is set and not found in the first fetch,\n+    retries up to 6 times (handles eventual consistency).\n+    \"\"\"\n+    logger.debug(\"[Feishu-Comment] list_comment_replies: file_token=%s comment_id=%s\", file_token, comment_id)\n+\n+    for attempt in range(_COMMENT_RETRY_LIMIT):\n+        all_replies: List[Dict[str, Any]] = []\n+        page_token = \"\"\n+        fetch_ok = True\n+\n+        for _ in range(5):  # max 5 pages\n+            queries = [\n+                (\"file_type\", file_type),\n+                (\"page_size\", \"100\"),\n+                (\"user_id_type\", \"open_id\"),\n+            ]\n+            if page_token:\n+                queries.append((\"page_token\", page_token))\n+\n+            code, msg, data = await _exec_request(\n+                client, \"GET\", _LIST_REPLIES_URI,\n+                paths={\"file_token\": [redacted:secret-assignment] \"comment_id\": comment_id},\n+                queries=queries,\n+            )\n+            if code != 0:\n+                logger.warning(\"[Feishu-Comment] List replies failed: code=%s msg=%s\", code, msg)\n+                fetch_ok = False\n+                break\n+\n+            items = data.get(\"items\", [])\n+            if isinstance(items, list):\n+                all_replies.extend(items)\n+\n+            if not data.get(\"has_more\"):\n+                break\n+            page_token = data.get(\"page_token\", \"\")\n+            if not page_token:\n+                break\n+\n+        # If we don't need a specific reply, or we found it, return\n+        if not expect_reply_id or not fetch_ok:\n+            break\n+        found = any(r.get(\"reply_id\") == expect_reply_id for r in all_replies)\n+        if found:\n+            break\n+        if attempt < _COMMENT_RETRY_LIMIT - 1:\n+            logger.info(\n+                \"[Feishu-Comment] list_comment_replies: reply_id=%s not found, retry %d/%d\",\n+                expect_reply_id, attempt + 1, _COMMENT_RETRY_LIMIT,\n+            )\n+            await asyncio.sleep(_COMMENT_RETRY_DELAY_S)\n+        else:\n+            logger.warning(\n+                \"[Feishu-Comment] list_comment_replies: reply_id=%s not found after %d attempts\",\n+                expect_reply_id, _COMMENT_RETRY_LIMIT,\n+            )\n+\n+    logger.info(\"[Feishu-Comment] list_comment_replies: total %d replies fetched\", len(all_replies))\n+    return all_replies\n+\n+\n+def _sanitize_comment_text(text: str) -> str:\n+    \"\"\"Escape characters not allowed in Feishu comment text_run content.\"\"\"\n+    return text.replace(\"&\", \"&amp;\").replace(\"<\", \"&lt;\").replace(\">\", \"&gt;\")\n+\n+\n+async def reply_to_comment(\n+    client: Any, file_token: str, file_type: str, comment_id: str, text: str,\n+) -> Tuple[bool, int]:\n+    \"\"\"Post a reply to a local comment thread.\n+\n+    Returns ``(success, code)``.\n+    \"\"\"\n+    text = _sanitize_comment_text(text)\n+    logger.info(\"[Feishu-Comment] reply_to_comment: comment_id=%s text=%s\",\n+                comment_id, text[:100])\n+    body = {\n+        \"content\": {\n+            \"elements\": [\n+                {\"type\": \"text_run\", \"text_run\": {\"text\": text}},\n+            ]\n+        }\n+    }\n+\n+    code, msg, _ = await _exec_request(\n+        client, \"POST\", _REPLY_COMMENT_URI,\n+        paths={\"file_token\": [redacted:secret-assignment] \"comment_id\": comment_id},\n+        queries=[(\"file_type\", file_type)],\n+        body=body,\n+    )\n+    if code != 0:\n+        logger.warning(\n+            \"[Feishu-Comment] reply_to_comment FAILED: code=%s msg=%s comment_id=%s\",\n+            code, msg, comment_id,\n+        )\n+    else:\n+        logger.info(\"[Feishu-Comment] reply_to_comment OK: comment_id=%s\", comment_id)\n+    return code == 0, code\n+\n+\n+async def add_whole_comment(\n+    client: Any, file_token: str, file_type: str, text: str,\n+) -> bool:\n+    \"\"\"Add a new whole-document comment.\n+\n+    Returns ``True`` on success.\n+    \"\"\"\n+    text = _sanitize_comment_text(text)\n+    logger.info(\"[Feishu-Comment] add_whole_comment: file_token=%s text=%s\",\n+                file_token, text[:100])\n+    body = {\n+        \"file_type\": file_type,\n+        \"reply_elements\": [\n+            {\"type\": \"text\", \"text\": text},\n+        ],\n+    }\n+\n+    code, msg, _ = await _exec_request(\n+        client, \"POST\", _ADD_COMMENT_URI,\n+        paths={\"file_token\": [redacted:secret-assignment]\n+        body=body,\n+    )\n+    if code != 0:\n+        logger.warning(\"[Feishu-Comment] add_whole_comment FAILED: code=%s msg=%s\", code, msg)\n+    else:\n+        logger.info(\"[Feishu-Comment] add_whole_comment OK\")\n+    return code == 0\n+\n+\n+_REPLY_CHUNK_SIZE = 4000\n+\n+\n+def _chunk_text(text: str, limit: int = _REPLY_CHUNK_SIZE) -> List[str]:\n+    \"\"\"Split text into chunks for delivery, preferring line breaks.\"\"\"\n+    if len(text) <= limit:\n+        return [text]\n+    chunks = []\n+    while text:\n+        if len(text) <= limit:\n+            chunks.append(text)\n+            break\n+        # Find last newline within limit\n+        cut = text.rfind(\"\\n\", 0, limit)\n+        if cut <= 0:\n+            cut = limit\n+        chunks.append(text[:cut])\n+        text = text[cut:].lstrip(\"\\n\")\n+    return chunks\n+\n+\n+async def deliver_comment_reply(\n+    client: Any,\n+    file_token: str,\n+    file_type: str,\n+    comment_id: str,\n+    text: str,\n+    is_whole: bool,\n+) -> bool:\n+    \"\"\"Route agent reply to the correct API, chunking long text.\n+\n+    - Whole comment -> add_whole_comment\n+    - Local comment -> reply_to_comment, fallback to add_whole_comment on 1069302\n+    \"\"\"\n+    chunks = _chunk_text(text)\n+    logger.info(\"[Feishu-Comment] deliver_comment_reply: is_whole=%s comment_id=%s text_len=%d chunks=%d\",\n+                is_whole, comment_id, len(text), len(chunks))\n+\n+    all_ok = True\n+    for i, chunk in enumerate(chunks):\n+        if len(chunks) > 1:\n+            logger.info(\"[Feishu-Comment] deliver_comment_reply: sending chunk %d/%d (%d chars)\",\n+                        i + 1, len(chunks), len(chunk))\n+\n+        if is_whole:\n+            ok = await add_whole_comment(client, file_token, file_type, chunk)\n+        else:\n+            success, code = await reply_to_comment(client, file_token, file_type, comment_id, chunk)\n+            if success:\n+                ok = True\n+            elif code == 1069302:\n+                logger.info(\"[Feishu-Comment] Reply not allowed (1069302), falling back to add_whole_comment\")\n+                ok = await add_whole_comment(client, file_token, file_type, chunk)\n+                is_whole = True  # subsequent chunks also use add_comment\n+            else:\n+                ok = False\n+\n+        if not ok:\n+            all_ok = False\n+            break\n+\n+    return all_ok\n+\n+\n+# ---------------------------------------------------------------------------\n+# Comment content extraction helpers\n+# ---------------------------------------------------------------------------\n+\n+\n+def _extract_reply_text(reply: Dict[str, Any]) -> str:\n+    \"\"\"Extract plain text from a comment reply's content structure.\"\"\"\n+    content = reply.get(\"content\", {})\n+    if isinstance(content, str):\n+        try:\n+            content = json.loads(content)\n+        except (json.JSONDecodeError, TypeError):\n+            return content\n+\n+    elements = content.get(\"elements\", [])\n+    parts = []\n+    for elem in elements:\n+        if elem.get(\"type\") == \"text_run\":\n+            text_run = elem.get(\"text_run\", {})\n+            parts.append(text_run.get(\"text\", \"\"))\n+        elif elem.get(\"type\") == \"docs_link\":\n+            docs_link = elem.get(\"docs_link\", {})\n+            parts.append(docs_link.get(\"url\", \"\"))\n+        elif elem.get(\"type\") == \"person\":\n+            person = elem.get(\"person\", {})\n+            parts.append(f\"@{person.get('user_id', 'unknown')}\")\n+    return \"\".join(parts)\n+\n+\n+def _get_reply_user_id(reply: Dict[str, Any]) -> str:\n+    \"\"\"Extract user_id from a reply dict.\"\"\"\n+    user_id = reply.get(\"user_id\", \"\")\n+    if isinstance(user_id, dict):\n+        return user_id.get(\"open_id\", \"\") or user_id.get(\"user_id\", \"\")\n+    return str(user_id)\n+\n+\n+def _extract_semantic_text(reply: Dict[str, Any], self_open_id: str = \"\") -> str:\n+    \"\"\"Extract semantic text from a reply, stripping self @mentions and extra whitespace.\"\"\"\n+    content = reply.get(\"content\", {})\n+    if isinstance(content, str):\n+        try:\n+            content = json.loads(content)\n+        except (json.JSONDecodeError, TypeError):\n+            return content\n+\n+    elements = content.get(\"elements\", [])\n+    parts = []\n+    for elem in elements:\n+        if elem.get(\"type\") == \"person\":\n+            person = elem.get(\"person\", {})\n+            uid = person.get(\"user_id\", \"\")\n+            # Skip self @mention (it's routing, not content)\n+            if self_open_id and uid == self_open_id:\n+                continue\n+            parts.append(f\"@{uid}\")\n+        elif elem.get(\"type\") == \"text_run\":\n+            text_run = elem.get(\"text_run\", {})\n+            parts.append(text_run.get(\"text\", \"\"))\n+        elif elem.get(\"type\") == \"docs_link\":\n+            docs_link = elem.get(\"docs_link\", {})\n+            parts.append(docs_link.get(\"url\", \"\"))\n+    return \" \".join(\"\".join(parts).split()).strip()\n+\n+\n+# ---------------------------------------------------------------------------\n+# Document link parsing and wiki resolution\n+# ---------------------------------------------------------------------------\n+\n+import re as _re\n+\n+# Matches feishu/lark document URLs and extracts doc_type + token\n+_FEISHU_DOC_URL_RE = _re.compile(\n+    r\"(?:feishu\\.cn|larkoffice\\.com|larksuite\\.com|lark\\.suite\\.com)\"\n+    r\"/(?P<doc_type>wiki|doc|docx|sheet|sheets|slides|mindnote|bitable|base|file)\"\n+    r\"/(?P<token>[A-Za-z0-9_-]{10,40})\"\n+)\n+\n+_WIKI_GET_NODE_URI = \"/open-apis/wiki/v2/spaces/get_node\"\n+\n+\n+def _extract_docs_links(replies: List[Dict[str, Any]]) -> List[Dict[str, str]]:\n+    \"\"\"Extract unique document links from a list of comment replies.\n+\n+    Returns list of ``{\"url\": \"...\", \"doc_type\": \"...\", \"token\": \"...\"}`` dicts.\n+    \"\"\"\n+    seen_tokens = set()\n+    links = []\n+    for reply in replies:\n+        content = reply.get(\"content\", {})\n+        if isinstance(content, str):\n+            try:\n+                content = json.loads(content)\n+            except (json.JSONDecodeError, TypeError):\n+                continue\n+        for elem in content.get(\"elements\", []):\n+            if elem.get(\"type\") not in (\"docs_link\", \"link\"):\n+                continue\n+            link_data = elem.get(\"docs_link\") or elem.get(\"link\") or {}\n+            url = link_data.get(\"url\", \"\")\n+            if not url:\n+                continue\n+            m = _FEISHU_DOC_URL_RE.search(url)\n+            if not m:\n+                continue\n+            doc_type = m.group(\"doc_type\")\n+            token = m.group(\"token\")\n+            if token in seen_tokens:\n+                continue\n+            seen_tokens.add(token)\n+            links.append({\"url\": url, \"doc_type\": doc_type, \"token\": [redacted:secret-assignment]\n+    return links\n+\n+\n+async def _reverse_lookup_wiki_token(\n+    client: Any, obj_type: str, obj_token: str,\n+) -> Optional[str]:\n+    \"\"\"Reverse-lookup: given an obj_token, find its wiki node_token.\n+\n+    Returns the wiki_token if the document belongs to a wiki space,\n+    or None if it doesn't or the API call fails.\n+    \"\"\"\n+    code, msg, data = await _exec_request(\n+        client, \"GET\", _WIKI_GET_NODE_URI,\n+        queries=[(\"token\", obj_token), (\"obj_type\", obj_type)],\n+    )\n+    if code == 0:\n+        node = data.get(\"node\", {})\n+        wiki_token = node.get(\"node_token\", \"\")\n+        return wiki_token if wiki_token else None\n+    # code != 0: either not a wiki doc or service error — log and return None\n+    logger.warning(\"[Feishu-Comment] Wiki reverse lookup failed: code=%s msg=%s obj=%s:%s\", code, msg, obj_type, obj_token)\n+    return None\n+\n+\n+async def _resolve_wiki_nodes(\n+    client: Any,\n+    links: List[Dict[str, str]],\n+) -> List[Dict[str, str]]:\n+    \"\"\"Resolve wiki links to their underlying document type and token.\n+\n+    Mutates entries in *links* in-place: replaces ``doc_type`` and ``token``\n+    with the resolved values for wiki links.  Non-wiki links are unchanged.\n+    \"\"\"\n+    wiki_links = [l for l in links if l[\"doc_type\"] == \"wiki\"]\n+    if not wiki_links:\n+        return links\n+\n+    for link in wiki_links:\n+        wiki_token = link[\"token\"]\n+        code, msg, data = await _exec_request(\n+            client, \"GET\", _WIKI_GET_NODE_URI,\n+            queries=[(\"token\", wiki_token)],\n+        )\n+        if code == 0:\n+            node = data.get(\"node\", {})\n+            resolved_type = node.get(\"obj_type\", \"\")\n+            resolved_token = node.get(\"obj_token\", \"\")\n+            if resolved_type and resolved_token:\n+                logger.info(\n+                    \"[Feishu-Comment] Wiki resolved: %s -> %s:%s\",\n+                    wiki_token, resolved_type, resolved_token,\n+                )\n+                link[\"resolved_type\"] = resolved_type\n+                link[\"resolved_token\"] = resolved_token\n+            else:\n+                logger.warning(\"[Feishu-Comment] Wiki resolve returned empty: %s\", wiki_token)\n+        else:\n+            logger.warning(\"[Feishu-Comment] Wiki resolve failed: code=%s msg=%s token=%s\", code, msg, wiki_token)\n+\n+    return links\n+\n+\n+def _format_referenced_docs(\n+    links: List[Dict[str, str]], current_file_token: str = \"\",\n+) -> str:\n+    \"\"\"Format resolved document links for prompt embedding.\"\"\"\n+    if not links:\n+        return \"\"\n+    lines = [\"\", \"Referenced documents in comments:\"]\n+    for link in links:\n+        rtype = link.get(\"resolved_type\", link[\"doc_type\"])\n+        rtoken = link.get(\"resolved_token\", link[\"token\"])\n+        is_current = rtoken == current_file_token\n+        suffix = \" (same as current document)\" if is_current else \"\"\n+        lines.append(f\"- {rtype}:{rtoken}{suffix} ({link['url'][:80]})\")\n+    return \"\\n\".join(lines)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Prompt construction\n+# ---------------------------------------------------------------------------\n+\n+_PROMPT_TEXT_LIMIT = 220\n+_LOCAL_TIMELINE_LIMIT = 20\n+_WHOLE_TIMELINE_LIMIT = 12\n+\n+\n+def _truncate(text: str, limit: int = _PROMPT_TEXT_LIMIT) -> str:\n+    \"\"\"Truncate text for prompt embedding.\"\"\"\n+    if len(text) <= limit:\n+        return text\n+    return text[:limit] + \"...\"\n+\n+\n+def _select_local_timeline(\n+    timeline: List[Tuple[str, str, bool]],\n+    target_index: int,\n+) -> List[Tuple[str, str, bool]]:\n+    \"\"\"Select up to _LOCAL_TIMELINE_LIMIT entries centered on target_index.\n+\n+    Always keeps first, target, and last entries.\n+    \"\"\"\n+    if len(timeline) <= _LOCAL_TIMELINE_LIMIT:\n+        return timeline\n+    n = len(timeline)\n+    selected = set()\n+    selected.add(0)                            # first\n+    selected.add(n - 1)                        # last\n+    if 0 <= target_index < n:\n+        selected.add(target_index)             # current\n+    # Expand outward from target\n+    budget = _LOCAL_TIMELINE_LIMIT - len(selected)\n+    lo, hi = target_index - 1, target_index + 1\n+    while budget > 0 and (lo >= 0 or hi < n):\n+        if lo >= 0 and lo not in selected:\n+            selected.add(lo)\n+            budget -= 1\n+        lo -= 1\n+        if budget > 0 and hi < n and hi not in selected:\n+            selected.add(hi)\n+            budget -= 1\n+        hi += 1\n+    return [timeline[i] for i in sorted(selected)]\n+\n+\n+def _select_whole_timeline(\n+    timeline: List[Tuple[str, str, bool]],\n+    current_index: int,\n+    nearest_self_index: int,\n+) -> List[Tuple[str, str, bool]]:\n+    \"\"\"Select up to _WHOLE_TIMELINE_LIMIT entries for whole-doc comments.\n+\n+    Prioritizes current entry and nearest self reply.\n+    \"\"\"\n+    if len(timeline) <= _WHOLE_TIMELINE_LIMIT:\n+        return timeline\n+    n = len(timeline)\n+    selected = set()\n+    if 0 <= current_index < n:\n+        selected.add(current_index)\n+    if 0 <= nearest_self_index < n:\n+        selected.add(nearest_self_index)\n+    # Expand outward from current\n+    budget = _WHOLE_TIMELINE_LIMIT - len(selected)\n+    lo, hi = current_index - 1, current_index + 1\n+    while budget > 0 and (lo >= 0 or hi < n):\n+        if lo >= 0 and lo not in selected:\n+            selected.add(lo)\n+            budget -= 1\n+        lo -= 1\n+        if budget > 0 and hi < n and hi not in selected:\n+            selected.add(hi)\n+            budget -= 1\n+        hi += 1\n+    if not selected:\n+        # Fallback: take last N entries\n+        return timeline[-_WHOLE_TIMELINE_LIMIT:]\n+    return [timeline[i] for i in sorted(selected)]\n+\n+\n+_COMMON_INSTRUCTIONS = \"\"\"\n+This is a Feishu document comment thread, not an IM chat.\n+Do NOT call feishu_drive_add_comment or feishu_drive_reply_comment yourself.\n+Your reply will be posted automatically. Just output the reply text.\n+Use the thread timeline above as the main context.\n+If the quoted content is not enough, use feishu_doc_read to read nearby context.\n+The quoted content is your primary anchor — insert/summarize/explain requests are about it.\n+Do not guess document content you haven't read.\n+Reply in the same language as the user's comment unless they request otherwise.\n+Use plain text only. Do not use Markdown, headings, bullet lists, tables, or code blocks.\n+Do not show your reasoning process. Do not start with \"I will\", \"Let me\", or \"I'll first\".\n+Output only the final user-facing reply.\n+If no reply is needed, output exactly NO_REPLY.\n+\"\"\".strip()\n+\n+\n+def build_local_comment_prompt(\n+    *,\n+    doc_title: str,\n+    doc_url: str,\n+    file_token: str,\n+    file_type: str,\n+    comment_id: str,\n+    quote_text: str,\n+    root_comment_text: str,\n+    target_reply_text: str,\n+    timeline: List[Tuple[str, str, bool]],  # [(user_id, text, is_self)]\n+    self_open_id: str,\n+    target_index: int = -1,\n+    referenced_docs: str = \"\",\n+) -> str:\n+    \"\"\"Build the prompt for a local (quoted-text) comment.\"\"\"\n+    selected = _select_local_timeline(timeline, target_index)\n+\n+    lines = [\n+        f'The user added a reply in \"{doc_title}\".',\n+        f'Current user comment text: \"{_truncate(target_reply_text)}\"',\n+        f'Original comment text: \"{_truncate(root_comment_text)}\"',\n+        f'Quoted content: \"{_truncate(quote_text, 500)}\"',\n+        \"This comment mentioned you (@mention is for routing, not task content).\",\n+        f\"Document link: {doc_url}\",\n+        \"Current commented document:\",\n+        f\"- file_type={file_type}\",\n+        f\"- file_token={file_token}\",\n+        f\"- comment_id={comment_id}\",\n+        \"\",\n+        f\"Current comment card timeline ({len(selected)}/{len(timeline)} entries):\",\n+    ]\n+\n+    for user_id, text, is_self in selected:\n+        marker = \" <-- YOU\" if is_self else \"\"\n+        lines.append(f\"[{user_id}] {_truncate(text)}{marker}\")\n+\n+    if referenced_docs:\n+        lines.append(referenced_docs)\n+\n+    lines.append(\"\")\n+    lines.append(_COMMON_INSTRUCTIONS)\n+    return \"\\n\".join(lines)\n+\n+\n+def build_whole_comment_prompt(\n+    *,\n+    doc_title: str,\n+    doc_url: str,\n+    file_token: str,\n+    file_type: str,\n+    comment_text: str,\n+    timeline: List[Tuple[str, str, bool]],  # [(user_id, text, is_self)]\n+    self_open_id: str,\n+    current_index: int = -1,\n+    nearest_self_index: int = -1,\n+    referenced_docs: str = \"\",\n+) -> str:\n+    \"\"\"Build the prompt for a whole-document comment.\"\"\"\n+    selected = _select_whole_timeline(timeline, current_index, nearest_self_index)\n+\n+    lines = [\n+        f'The user added a comment in \"{doc_title}\".',\n+        f'Current user comment text: \"{_truncate(comment_text)}\"',\n+        \"This is a whole-document comment.\",\n+        \"This comment mentioned you (@mention is for routing, not task content).\",\n+        f\"Document link: {doc_url}\",\n+        \"Current commented document:\",\n+        f\"- file_type={file_type}\",\n+        f\"- file_token={file_token}\",\n+        \"\",\n+        f\"Whole-document comment timeline ({len(selected)}/{len(timeline)} entries):\",\n+    ]\n+\n+    for user_id, text, is_self in selected:\n+        marker = \" <-- YOU\" if is_self else \"\"\n+        lines.append(f\"[{user_id}] {_truncate(text)}{marker}\")\n+\n+    if referenced_docs:\n+        lines.append(referenced_docs)\n+\n+    lines.append(\"\")\n+    lines.append(_COMMON_INSTRUCTIONS)\n+    return \"\\n\".join(lines)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Agent execution\n+# ---------------------------------------------------------------------------\n+\n+\n+def _resolve_model_and_runtime() -> Tuple[str, dict]:\n+    \"\"\"Resolve model and provider credentials, same as gateway message handling.\"\"\"\n+    import os\n+    from gateway.run import _load_gateway_config, _resolve_gateway_model\n+\n+    user_config = _load_gateway_config()\n+    model = _resolve_gateway_model(user_config)\n+\n+    from gateway.run import _resolve_runtime_agent_kwargs\n+    runtime_kwargs = _resolve_runtime_agent_kwargs()\n+\n+    # Fall back to provider's default model if none configured\n+    if not model and runtime_kwargs.get(\"provider\"):\n+        try:\n+            from hermes_cli.models import get_default_model_for_provider\n+            model = get_default_model_for_provider(runtime_kwargs[\"provider\"])\n+        except Exception:\n+            pass\n+\n+    return model, runtime_kwargs\n+\n+\n+# ---------------------------------------------------------------------------\n+# Session cache for cross-card memory within the same document\n+# ---------------------------------------------------------------------------\n+\n+import threading\n+import time as _time\n+\n+_SESSION_MAX_MESSAGES = 50  # keep last N messages per document session\n+_SESSION_TTL_S = 3600       # expire sessions after 1 hour of inactivity\n+\n+_session_cache_lock = threading.Lock()\n+_session_cache: Dict[str, Dict] = {}  # key -> {\"messages\": [...], \"last_access\": float}\n+\n+\n+def _session_key(file_type: str, file_token: str) -> str:\n+    return f\"comment-doc:{file_type}:{file_token}\"\n+\n+\n+def _load_session_history(key: str) -> List[Dict[str, Any]]:\n+    \"\"\"Load conversation history for a document session.\"\"\"\n+    with _session_cache_lock:\n+        entry = _session_cache.get(key)\n+        if entry is None:\n+            return []\n+        # Check TTL\n+        if _time.time() - entry[\"last_access\"] > _SESSION_TTL_S:\n+            del _session_cache[key]\n+            logger.info(\"[Feishu-Comment] Session expired: %s\", key)\n+            return []\n+        entry[\"last_access\"] = _time.time()\n+        return list(entry[\"messages\"])\n+\n+\n+def _save_session_history(key: str, messages: List[Dict[str, Any]]) -> None:\n+    \"\"\"Save conversation history for a document session (keeps last N messages).\"\"\"\n+    # Only keep user/assistant messages (strip system messages and tool internals)\n+    cleaned = [\n+        m for m in messages\n+        if m.get(\"role\") in (\"user\", \"assistant\") and m.get(\"content\")\n+    ]\n+    # Keep last N\n+    if len(cleaned) > _SESSION_MAX_MESSAGES:\n+        cleaned = cleaned[-_SESSION_MAX_MESSAGES:]\n+    with _session_cache_lock:\n+        _session_cache[key] = {\n+            \"messages\": cleaned,\n+            \"last_access\": _time.time(),\n+        }\n+        logger.info(\"[Feishu-Comment] Session saved: %s (%d messages)\", key, len(cleaned))\n+\n+\n+def _run_comment_agent(prompt: str, client: Any, session_key: str = \"\") -> str:\n+    \"\"\"Create an AIAgent with feishu tools and run the prompt.\n+\n+    If *session_key* is provided, loads/saves conversation history for\n+    cross-card memory within the same document.\n+\n+    Returns the agent's final response text, or empty string on failure.\n+    \"\"\"\n+    from run_agent import AIAgent\n+\n+    logger.info(\"[Feishu-Comment] _run_comment_agent: injecting lark client into tool thread-locals\")\n+    from tools.feishu_doc_tool import set_client as set_doc_client\n+    from tools.feishu_drive_tool import set_client as set_drive_client\n+    set_doc_client(client)\n+    set_drive_client(client)\n+\n+    try:\n+        model, runtime_kwargs = _resolve_model_and_runtime()\n+        logger.info(\"[Feishu-Comment] _run_comment_agent: model=%s provider=%s base_url=%s\",\n+                    model, runtime_kwargs.get(\"provider\"), (runtime_kwargs.get(\"base_url\") or \"\")[:50])\n+\n+        # Load session history for cross-card memory\n+        history = _load_session_history(session_key) if session_key else []\n+        if history:\n+            logger.info(\"[Feishu-Comment] _run_comment_agent: loaded %d history messages from session %s\",\n+                        len(history), session_key)\n+\n+        agent = AIAgent(\n+            model=model,\n+            base_url=runtime_kwargs.get(\"base_url\"),\n+            api_key=runtime_kwargs.get(\"api_key\"),\n+            provider=runtime_kwargs.get(\"provider\"),\n+            api_mode=runtime_kwargs.get(\"api_mode\"),\n+            credential_pool=runtime_kwargs.get(\"credential_pool\"),\n+            quiet_mode=True,\n+            skip_context_files=True,\n+            skip_memory=True,\n+            max_iterations=15,\n+            enabled_toolsets=[\"feishu_doc\", \"feishu_drive\"],\n+        )\n+        logger.info(\"[Feishu-Comment] _run_comment_agent: calling run_conversation (prompt=%d chars, history=%d)\",\n+                    len(prompt), len(history))\n+        result = agent.run_conversation(prompt, conversation_history=history or None)\n+        response = (result.get(\"final_response\") or \"\").strip()\n+        api_calls = result.get(\"api_calls\", 0)\n+        logger.info(\"[Feishu-Comment] _run_comment_agent: done api_calls=%d response_len=%d response=%s\",\n+                    api_calls, len(response), response[:200])\n+\n+        # Save updated history\n+        if session_key:\n+            new_messages = result.get(\"messages\", [])\n+            if new_messages:\n+                _save_session_history(session_key, new_messages)\n+\n+        return response\n+    except Exception as e:\n+        logger.exception(\"[Feishu-Comment] _run_comment_agent: agent failed: %s\", e)\n+        return \"\"\n+    finally:\n+        set_doc_client(None)\n+        set_drive_client(None)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Event handler entry point\n+# ---------------------------------------------------------------------------\n+\n+_NO_REPLY_SENTINEL = \"NO_REPLY\"\n+\n+\n+_ALLOWED_NOTICE_TYPES = {\"add_comment\", \"add_reply\"}\n+\n+\n+async def handle_drive_comment_event(\n+    client: Any, data: Any, *, self_open_id: str = \"\",\n+) -> None:\n+    \"\"\"Full orchestration for a drive comment event.\n+\n+    1. Parse event + filter (self-reply, notice_type)\n+    2. Add OK reaction\n+    3. Fetch doc meta + comment details in parallel\n+    4. Branch on is_whole: build timeline\n+    5. Build prompt, run agent\n+    6. Deliver reply\n+    \"\"\"\n+    logger.info(\"[Feishu-Comment] ========== handle_drive_comment_event START ==========\")\n+    parsed = parse_drive_comment_event(data)\n+    if parsed is None:\n+        logger.warning(\"[Feishu-Comment] Dropping malformed drive comment event\")\n+        return\n+    logger.info(\"[Feishu-Comment] [Step 0/5] Event parsed successfully\")\n+\n+    file_token = [redacted:secret-assignment]\"file_token\"]\n+    file_type = parsed[\"file_type\"]\n+    comment_id = parsed[\"comment_id\"]\n+    reply_id = parsed[\"reply_id\"]\n+    from_open_id = parsed[\"from_open_id\"]\n+    to_open_id = parsed[\"to_open_id\"]\n+    notice_type = parsed[\"notice_type\"]\n+\n+    # Filter: self-reply, receiver check, notice_type\n+    if from_open_id and self_open_id and from_open_id == self_open_id:\n+        logger.debug(\"[Feishu-Comment] Skipping self-authored event: from=%s\", from_open_id)\n+        return\n+    if not to_open_id or (self_open_id and to_open_id != self_open_id):\n+        logger.debug(\"[Feishu-Comment] Skipping event not addressed to self: to=%s\", to_open_id or \"(empty)\")\n+        return\n+    if notice_type and notice_type not in _ALLOWED_NOTICE_TYPES:\n+        logger.debug(\"[Feishu-Comment] Skipping notice_type=%s\", notice_type)\n+        return\n+    if not file_token or not file_type or not comment_id:\n+        logger.warning(\"[Feishu-Comment] Missing required fields, skipping\")\n+        return\n+\n+    logger.info(\n+        \"[Feishu-Comment] Event: notice=%s file=%s:%s comment=%s from=%s\",\n+        notice_type, file_type, file_token, comment_id, from_open_id,\n+    )\n+\n+    # Access control\n+    from gateway.platforms.feishu_comment_rules import load_config, resolve_rule, is_user_allowed, has_wiki_keys\n+\n+    comments_cfg = load_config()\n+    rule = resolve_rule(comments_cfg, file_type, file_token)\n+\n+    # If no exact match and config has wiki keys, try reverse-lookup\n+    if rule.match_source in (\"wildcard\", \"top\") and has_wiki_keys(comments_cfg):\n+        wiki_token = await _reverse_lookup_wiki_token(client, file_type, file_token)\n+        if wiki_token:\n+            rule = resolve_rule(comments_cfg, file_type, file_token, wiki_token=[redacted:secret-assignment]\n+\n+    if not rule.enabled:\n+        logger.info(\"[Feishu-Comment] Comments disabled for %s:%s, skipping\", file_type, file_token)\n+        return\n+    if not is_user_allowed(rule, from_open_id):\n+        logger.info(\"[Feishu-Comment] User %s denied (policy=%s, rule=%s)\", from_open_id, rule.policy, rule.match_source)\n+        return\n+\n+    logger.info(\"[Feishu-Comment] Access granted: user=%s policy=%s rule=%s\", from_open_id, rule.policy, rule.match_source)\n+    if reply_id:\n+        asyncio.ensure_future(\n+            add_comment_reaction(\n+                client,\n+                file_token=[redacted:secret-assignment]\n+                file_type=file_type,\n+                reply_id=reply_id,\n+                reaction_type=\"OK\",\n+            )\n+        )\n+\n+    # Step 2: Parallel fetch -- doc meta + comment details\n+    logger.info(\"[Feishu-Comment] [Step 2/5] Parallel fetch: doc meta + comment batch_query\")\n+    meta_task = asyncio.ensure_future(\n+        query_document_meta(client, file_token, file_type)\n+    )\n+    comment_task = asyncio.ensure_future(\n+        batch_query_comment(client, file_token, file_type, comment_id)\n+    )\n+    doc_meta, comment_detail = await asyncio.gather(meta_task, comment_task)\n+\n+    doc_title = doc_meta.get(\"title\", \"Untitled\")\n+    doc_url = doc_meta.get(\"url\", \"\")\n+    is_whole = bool(comment_detail.get(\"is_whole\"))\n+\n+    logger.info(\n+        \"[Feishu-Comment] Comment context: title=%s is_whole=%s\",\n+        doc_title, is_whole,\n+    )\n+\n+    # Step 3: Build timeline based on comment type\n+    logger.info(\"[Feishu-Comment] [Step 3/5] Building timeline (is_whole=%s)\", is_whole)\n+    if is_whole:\n+        # Whole-document comment: fetch all whole comments as timeline\n+        logger.info(\"[Feishu-Comment] Fetching whole-document comments for timeline...\")\n+        whole_comments = await list_whole_comments(client, file_token, file_type)\n+\n+        timeline: List[Tuple[str, str, bool]] = []\n+        current_text = \"\"\n+        current_index = -1\n+        nearest_self_index = -1\n+        for wc in whole_comments:\n+            reply_list = wc.get(\"reply_list\", {})\n+            if isinstance(reply_list, str):\n+                try:\n+                    reply_list = json.loads(reply_list)\n+                except (json.JSONDecodeError, TypeError):\n+                    reply_list = {}\n+            replies = reply_list.get(\"replies\", [])\n+            for r in replies:\n+                uid = _get_reply_user_id(r)\n+                text = _extract_reply_text(r)\n+                is_self = (uid == self_open_id) if self_open_id else False\n+                idx = len(timeline)\n+                timeline.append((uid, text, is_self))\n+                if uid == from_open_id:\n+                    current_text = _extract_semantic_text(r, self_open_id)\n+                    current_index = idx\n+                if is_self:\n+                    nearest_self_index = idx\n+\n+        if not current_text:\n+            for i, (uid, text, is_self) in reversed(list(enumerate(timeline))):\n+                if not is_self:\n+                    current_text = text\n+                    current_index = i\n+                    break\n+\n+        logger.info(\"[Feishu-Comment] Whole timeline: %d entries, current_idx=%d, self_idx=%d, text=%s\",\n+                    len(timeline), current_index, nearest_self_index,\n+                    current_text[:80] if current_text else \"(empty)\")\n+\n+        # Extract and resolve document links from all replies\n+        all_raw_replies = []\n+        for wc in whole_comments:\n+            rl = wc.get(\"reply_list\", {})\n+            if isinstance(rl, str):\n+                try:\n+                    rl = json.loads(rl)\n+                except (json.JSONDecodeError, TypeError):\n+                    rl = {}\n+            all_raw_replies.extend(rl.get(\"replies\", []))\n+        doc_links = _extract_docs_links(all_raw_replies)\n+        if doc_links:\n+            doc_links = await _resolve_wiki_nodes(client, doc_links)\n+        ref_docs_text = _format_referenced_docs(doc_links, file_token)\n+\n+        prompt = build_whole_comment_prompt(\n+            doc_title=doc_title,\n+            doc_url=doc_url,\n+            file_token=[redacted:secret-assignment]\n+            file_type=file_type,\n+            comment_text=current_text,\n+            timeline=timeline,\n+            self_open_id=self_open_id,\n+            current_index=current_index,\n+            nearest_self_index=nearest_self_index,\n+            referenced_docs=ref_docs_text,\n+        )\n+\n+    else:\n+        # Local comment: fetch the comment thread replies\n+        logger.info(\"[Feishu-Comment] Fetching comment thread replies...\")\n+        replies = await list_comment_replies(\n+            client, file_token, file_type, comment_id,\n+            expect_reply_id=reply_id,\n+        )\n+\n+        quote_text = comment_detail.get(\"quote\", \"\")\n+\n+        timeline = []\n+        root_text = \"\"\n+        target_text = \"\"\n+        target_index = -1\n+        for i, r in enumerate(replies):\n+            uid = _get_reply_user_id(r)\n+            text = _extract_reply_text(r)\n+            is_self = (uid == self_open_id) if self_open_id else False\n+            timeline.append((uid, text, is_self))\n+            if i == 0:\n+                root_text = _extract_semantic_text(r, self_open_id)\n+            rid = r.get(\"reply_id\", \"\")\n+            if rid and rid == reply_id:\n+                target_text = _extract_semantic_text(r, self_open_id)\n+                target_index = i\n+\n+        if not target_text and timeline:\n+            for i, (uid, text, is_self) in reversed(list(enumerate(timeline))):\n+                if uid == from_open_id:\n+                    target_text = text\n+                    target_index = i\n+                    break\n+\n+        logger.info(\"[Feishu-Comment] Local timeline: %d entries, target_idx=%d, quote=%s root=%s target=%s\",\n+                    len(timeline), target_index,\n+                    quote_text[:60] if quote_text else \"(empty)\",\n+                    root_text[:60] if root_text else \"(empty)\",\n+                    target_text[:60] if target_text else \"(empty)\")\n+\n+        # Extract and resolve document links from replies\n+        doc_links = _extract_docs_links(replies)\n+        if doc_links:\n+            doc_links = await _resolve_wiki_nodes(client, doc_links)\n+        ref_docs_text = _format_referenced_docs(doc_links, file_token)\n+\n+        prompt = build_local_comment_prompt(\n+            doc_title=doc_title,\n+            doc_url=doc_url,\n+            file_token=[redacted:secret-assignment]\n+            file_type=file_type,\n+            comment_id=comment_id,\n+            quote_text=quote_text,\n+            root_comment_text=root_text,\n+            target_reply_text=target_text,\n+            timeline=timeline,\n+            self_open_id=self_open_id,\n+            target_index=target_index,\n+            referenced_docs=ref_docs_text,\n+        )\n+\n+    logger.info(\"[Feishu-Comment] [Step 4/5] Prompt built (%d chars), running agent...\", len(prompt))\n+    logger.debug(\"[Feishu-Comment] Full prompt:\\n%s\", prompt)\n+\n+    # Step 4: Run agent in a thread (run_conversation is synchronous)\n+    # Session key groups all comment cards on the same document\n+    sess_key = _session_key(file_type, file_token)\n+    loop = asyncio.get_running_loop()\n+    response = await loop.run_in_executor(\n+        None, _run_comment_agent, prompt, client, sess_key,\n+    )\n+\n+    if not response or _NO_REPLY_SENTINEL in response:\n+        logger.info(\"[Feishu-Comment] Agent returned NO_REPLY, skipping delivery\")\n+    else:\n+        logger.info(\"[Feishu-Comment] Agent response (%d chars): %s\", len(response), response[:200])\n+\n+        # Step 5: Deliver reply\n+        logger.info(\"[Feishu-Comment] [Step 5/5] Delivering reply (is_whole=%s, comment_id=%s)\", is_whole, comment_id)\n+        success = await deliver_comment_reply(\n+            client, file_token, file_type, comment_id, response, is_whole,\n+        )\n+        if success:\n+            logger.info(\"[Feishu-Comment] Reply delivered successfully\")\n+        else:\n+            logger.error(\"[Feishu-Comment] Failed to deliver reply\")\n+\n+    # Cleanup: remove OK reaction (best-effort, non-blocking)\n+    if reply_id:\n+        await delete_comment_reaction(\n+            client,\n+            file_token=[redacted:secret-assignment]\n+            file_type=file_type,\n+            reply_id=reply_id,\n+            reaction_type=\"OK\",\n+        )\n+\n+    logger.info(\"[Feishu-Comment] ========== handle_drive_comment_event END ==========\")"]},{"file":"gateway/platforms/feishu_comment_rules.py","hunks":["@@ -0,0 +1,429 @@\n+\"\"\"\n+Feishu document comment access-control rules.\n+\n+3-tier rule resolution: exact doc > wildcard \"*\" > top-level > code defaults.\n+Each field (enabled/policy/allow_from) falls back independently.\n+Config: ~/.hermes/feishu_comment_rules.json (mtime-cached, hot-reload).\n+Pairing store: ~/.hermes/feishu_comment_pairing.json.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import json\n+import logging\n+import time\n+from dataclasses import dataclass, field\n+from pathlib import Path\n+from typing import Any, Dict, Optional\n+\n+from hermes_constants import get_hermes_home\n+\n+logger = logging.getLogger(__name__)\n+\n+# ---------------------------------------------------------------------------\n+# Paths\n+# ---------------------------------------------------------------------------\n+#\n+# Uses the canonical ``get_hermes_home()`` helper (HERMES_HOME-aware and\n+# profile-safe). Resolved at import time; this module is lazy-imported by\n+# the Feishu comment event handler, which runs long after profile overrides\n+# have been applied, so freezing paths here is safe.\n+\n+RULES_FILE = get_hermes_home() / \"feishu_comment_rules.json\"\n+PAIRING_FILE = get_hermes_home() / \"feishu_comment_pairing.json\"\n+\n+# ---------------------------------------------------------------------------\n+# Data models\n+# ---------------------------------------------------------------------------\n+\n+_VALID_POLICIES = (\"allowlist\", \"pairing\")\n+\n+\n+@dataclass(frozen=True)\n+class CommentDocumentRule:\n+    \"\"\"Per-document rule.  ``None`` means 'inherit from lower tier'.\"\"\"\n+    enabled: Optional[bool] = None\n+    policy: Optional[str] = None\n+    allow_from: Optional[frozenset] = None\n+\n+\n+@dataclass(frozen=True)\n+class CommentsConfig:\n+    \"\"\"Top-level comment access config.\"\"\"\n+    enabled: bool = True\n+    policy: str = \"pairing\"\n+    allow_from: frozenset = field(default_factory=frozenset)\n+    documents: Dict[str, CommentDocumentRule] = field(default_factory=dict)\n+\n+\n+@dataclass(frozen=True)\n+class ResolvedCommentRule:\n+    \"\"\"Fully resolved rule after field-by-field fallback.\"\"\"\n+    enabled: bool\n+    policy: str\n+    allow_from: frozenset\n+    match_source: str  # e.g. \"exact:docx:xxx\" | \"wildcard\" | \"top\" | \"default\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Mtime-cached file loading\n+# ---------------------------------------------------------------------------\n+\n+class _MtimeCache:\n+    \"\"\"Generic mtime-based file cache.  ``stat()`` per access, re-read only on change.\"\"\"\n+\n+    def __init__(self, path: Path):\n+        self._path = path\n+        self._mtime: float = 0.0\n+        self._data: Optional[dict] = None\n+\n+    def load(self) -> dict:\n+        try:\n+            st = self._path.stat()\n+            mtime = st.st_mtime\n+        except FileNotFoundError:\n+            self._mtime = 0.0\n+            self._data = {}\n+            return {}\n+\n+        if mtime == self._mtime and self._data is not None:\n+            return self._data\n+\n+        try:\n+            with open(self._path, \"r\", encoding=\"utf-8\") as f:\n+                data = json.load(f)\n+            if not isinstance(data, dict):\n+                data = {}\n+        except (json.JSONDecodeError, OSError):\n+            logger.warning(\"[Feishu-Rules] Failed to read %s, using empty config\", self._path)\n+            data = {}\n+\n+        self._mtime = mtime\n+        self._data = data\n+        return data\n+\n+\n+_rules_cache = _MtimeCache(RULES_FILE)\n+_pairing_cache = _MtimeCache(PAIRING_FILE)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Config parsing\n+# ---------------------------------------------------------------------------\n+\n+def _parse_frozenset(raw: Any) -> Optional[frozenset]:\n+    \"\"\"Parse a list of strings into a frozenset; return None if key absent.\"\"\"\n+    if raw is None:\n+        return None\n+    if isinstance(raw, (list, tuple)):\n+        return frozenset(str(u).strip() for u in raw if str(u).strip())\n+    return None\n+\n+\n+def _parse_document_rule(raw: dict) -> CommentDocumentRule:\n+    enabled = raw.get(\"enabled\")\n+    if enabled is not None:\n+        enabled = bool(enabled)\n+    policy = raw.get(\"policy\")\n+    if policy is not None:\n+        policy = str(policy).strip().lower()\n+        if policy not in _VALID_POLICIES:\n+            policy = None\n+    allow_from = _parse_frozenset(raw.get(\"allow_from\"))\n+    return CommentDocumentRule(enabled=enabled, policy=policy, allow_from=allow_from)\n+\n+\n+def load_config() -> CommentsConfig:\n+    \"\"\"Load comment rules from disk (mtime-cached).\"\"\"\n+    raw = _rules_cache.load()\n+    if not raw:\n+        return CommentsConfig()\n+\n+    documents: Dict[str, CommentDocumentRule] = {}\n+    raw_docs = raw.get(\"documents\", {})\n+    if isinstance(raw_docs, dict):\n+        for key, rule_raw in raw_docs.items():\n+            if isinstance(rule_raw, dict):\n+                documents[str(key)] = _parse_document_rule(rule_raw)\n+\n+    policy = str(raw.get(\"policy\", \"pairing\")).strip().lower()\n+    if policy not in _VALID_POLICIES:\n+        policy = \"pairing\"\n+\n+    return CommentsConfig(\n+        enabled=raw.get(\"enabled\", True),\n+        policy=policy,\n+        allow_from=_parse_frozenset(raw.get(\"allow_from\")) or frozenset(),\n+        documents=documents,\n+    )\n+\n+\n+# ---------------------------------------------------------------------------\n+# Rule resolution  (§8.4 field-by-field fallback)\n+# ---------------------------------------------------------------------------\n+\n+def has_wiki_keys(cfg: CommentsConfig) -> bool:\n+    \"\"\"Check if any document rule key starts with 'wiki:'.\"\"\"\n+    return any(k.startswith(\"wiki:\") for k in cfg.documents)\n+\n+\n+def resolve_rule(\n+    cfg: CommentsConfig,\n+    file_type: str,\n+    file_token: str,\n+    wiki_token: str = \"\",\n+) -> ResolvedCommentRule:\n+    \"\"\"Resolve effective rule: exact doc → wiki key → wildcard → top-level → defaults.\"\"\"\n+    exact_key = f\"{file_type}:{file_token}\"\n+\n+    exact = cfg.documents.get(exact_key)\n+    exact_src = f\"exact:{exact_key}\"\n+    if exact is None and wiki_token:\n+        wiki_key = f\"wiki:{wiki_token}\"\n+        exact = cfg.documents.get(wiki_key)\n+        exact_src = f\"exact:{wiki_key}\"\n+\n+    wildcard = cfg.documents.get(\"*\")\n+\n+    layers = []\n+    if exact is not None:\n+        layers.append((exact, exact_src))\n+    if wildcard is not None:\n+        layers.append((wildcard, \"wildcard\"))\n+\n+    def _pick(field_name: str):\n+        for layer, source in layers:\n+            val = getattr(layer, field_name)\n+            if val is not None:\n+                return val, source\n+        return getattr(cfg, field_name), \"top\"\n+\n+    enabled, en_src = _pick(\"enabled\")\n+    policy, pol_src = _pick(\"policy\")\n+    allow_from, _ = _pick(\"allow_from\")\n+\n+    # match_source = highest-priority tier that contributed any field\n+    priority_order = {\"exact\": 0, \"wildcard\": 1, \"top\": 2}\n+    best_src = min(\n+        [en_src, pol_src],\n+        key=lambda s: priority_order.get(s.split(\":\")[0], 3),\n+    )\n+\n+    return ResolvedCommentRule(\n+        enabled=enabled,\n+        policy=policy,\n+        allow_from=allow_from,\n+        match_source=best_src,\n+    )\n+\n+\n+# ---------------------------------------------------------------------------\n+# Pairing store\n+# ---------------------------------------------------------------------------\n+\n+def _load_pairing_approved() -> set:\n+    \"\"\"Return set of approved user open_ids (mtime-cached).\"\"\"\n+    data = _pairing_cache.load()\n+    approved = data.get(\"approved\", {})\n+    if isinstance(approved, dict):\n+        return set(approved.keys())\n+    if isinstance(approved, list):\n+        return set(str(u) for u in approved if u)\n+    return set()\n+\n+\n+def _save_pairing(data: dict) -> None:\n+    PAIRING_FILE.parent.mkdir(parents=True, exist_ok=True)\n+    tmp = PAIRING_FILE.with_suffix(\".tmp\")\n+    with open(tmp, \"w\", encoding=\"utf-8\") as f:\n+        json.dump(data, f, indent=2, ensure_ascii=False)\n+    tmp.replace(PAIRING_FILE)\n+    # Invalidate cache so next load picks up change\n+    _pairing_cache._mtime = 0.0\n+    _pairing_cache._data = None\n+\n+\n+def pairing_add(user_open_id: str) -> bool:\n+    \"\"\"Add a user to the pairing-approved list. Returns True if newly added.\"\"\"\n+    data = _pairing_cache.load()\n+    approved = data.get(\"approved\", {})\n+    if not isinstance(approved, dict):\n+        approved = {}\n+    if user_open_id in approved:\n+        return False\n+    approved[user_open_id] = {\"approved_at\": time.time()}\n+    data[\"approved\"] = approved\n+    _save_pairing(data)\n+    return True\n+\n+\n+def pairing_remove(user_open_id: str) -> bool:\n+    \"\"\"Remove a user from the pairing-approved list. Returns True if removed.\"\"\"\n+    data = _pairing_cache.load()\n+    approved = data.get(\"approved\", {})\n+    if not isinstance(approved, dict):\n+        return False\n+    if user_open_id not in approved:\n+        return False\n+    del approved[user_open_id]\n+    data[\"approved\"] = approved\n+    _save_pairing(data)\n+    return True\n+\n+\n+def pairing_list() -> Dict[str, Any]:\n+    \"\"\"Return the approved dict  {user_open_id: {approved_at: ...}}.\"\"\"\n+    data = _pairing_cache.load()\n+    approved = data.get(\"approved\", {})\n+    return dict(approved) if isinstance(approved, dict) else {}\n+\n+\n+# ---------------------------------------------------------------------------\n+# Access check  (public API for feishu_comment.py)\n+# ---------------------------------------------------------------------------\n+\n+def is_user_allowed(rule: ResolvedCommentRule, user_open_id: str) -> bool:\n+    \"\"\"Check if user passes the resolved rule's policy gate.\"\"\"\n+    if user_open_id in rule.allow_from:\n+        return True\n+    if rule.policy == \"pairing\":\n+        return user_open_id in _load_pairing_approved()\n+    return False\n+\n+\n+# ---------------------------------------------------------------------------\n+# CLI\n+# ---------------------------------------------------------------------------\n+\n+def _print_status() -> None:\n+    cfg = load_config()\n+    print(f\"Rules file: {RULES_FILE}\")\n+    print(f\"  exists: {RULES_FILE.exists()}\")\n+    print(f\"Pairing file: {PAIRING_FILE}\")\n+    print(f\"  exists: {PAIRING_FILE.exists()}\")\n+    print()\n+    print(f\"Top-level:\")\n+    print(f\"  enabled:    {cfg.enabled}\")\n+    print(f\"  policy:     {cfg.policy}\")\n+    print(f\"  allow_from: {sorted(cfg.allow_from) if cfg.allow_from else '[]'}\")\n+    print()\n+    if cfg.documents:\n+        print(f\"Document rules ({len(cfg.documents)}):\")\n+        for key, rule in sorted(cfg.documents.items()):\n+            parts = []\n+            if rule.enabled is not None:\n+                parts.append(f\"enabled={rule.enabled}\")\n+            if rule.policy is not None:\n+                parts.append(f\"policy={rule.policy}\")\n+            if rule.allow_from is not None:\n+                parts.append(f\"allow_from={sorted(rule.allow_from)}\")\n+            print(f\"  [{key}] {', '.join(parts) if parts else '(empty — inherits all)'}\")\n+    else:\n+        print(\"Document rules: (none)\")\n+    print()\n+    approved = pairing_list()\n+    print(f\"Pairing approved ({len(approved)}):\")\n+    for uid, meta in sorted(approved.items()):\n+        ts = meta.get(\"approved_at\", 0)\n+        print(f\"  {uid}  (approved_at={ts})\")\n+\n+\n+def _do_check(doc_key: str, user_open_id: str) -> None:\n+    cfg = load_config()\n+    parts = doc_key.split(\":\", 1)\n+    if len(parts) != 2:\n+        print(f\"Error: doc_key must be 'fileType:fileToken', got '{doc_key}'\")\n+        return\n+    file_type, file_token = parts\n+    rule = resolve_rule(cfg, file_type, file_token)\n+    allowed = is_user_allowed(rule, user_open_id)\n+    print(f\"Document:     {doc_key}\")\n+    print(f\"User:         {user_open_id}\")\n+    print(f\"Resolved rule:\")\n+    print(f\"  enabled:      {rule.enabled}\")\n+    print(f\"  policy:       {rule.policy}\")\n+    print(f\"  allow_from:   {sorted(rule.allow_from) if rule.allow_from else '[]'}\")\n+    print(f\"  match_source: {rule.match_source}\")\n+    print(f\"Result:       {'ALLOWED' if allowed else 'DENIED'}\")\n+\n+\n+def _main() -> int:\n+    import sys\n+\n+    try:\n+        from hermes_cli.env_loader import load_hermes_dotenv\n+        load_hermes_dotenv()\n+    except Exception:\n+        pass\n+\n+    usage = (\n+        \"Usage: python -m gateway.platforms.feishu_comment_rules <command> [args]\\n\"\n+        \"\\n\"\n+        \"Commands:\\n\"\n+        \"  status                              Show rules config and pairing state\\n\"\n+        \"  check <fileType:token> <user>        Simulate access check\\n\"\n+        \"  pairing add <user_open_id>           Add user to pairing-approved list\\n\"\n+        \"  pairing remove <user_open_id>        Remove user from pairing-approved list\\n\"\n+        \"  pairing list                         List pairing-approved users\\n\"\n+        \"\\n\"\n+        f\"Rules config file: {RULES_FILE}\\n\"\n+        \"  Edit this JSON file directly to configure policies and document rules.\\n\"\n+        \"  Changes take effect on the next comment event (no restart needed).\\n\"\n+    )\n+\n+    args = sys.argv[1:]\n+    if not args:\n+        print(usage)\n+        return 1\n+\n+    cmd = args[0]\n+\n+    if cmd == \"status\":\n+        _print_status()\n+\n+    elif cmd == \"check\":\n+        if len(args) < 3:\n+            print(\"Usage: check <fileType:fileToken> <user_open_id>\")\n+            return 1\n+        _do_check(args[1], args[2])\n+\n+    elif cmd == \"pairing\":\n+        if len(args) < 2:\n+            print(\"Usage: pairing <add|remove|list> [args]\")\n+            return 1\n+        sub = args[1]\n+        if sub == \"add\":\n+            if len(args) < 3:\n+                print(\"Usage: pairing add <user_open_id>\")\n+                return 1\n+            if pairing_add(args[2]):\n+                print(f\"Added: {args[2]}\")\n+            else:\n+                print(f\"Already approved: {args[2]}\")\n+        elif sub == \"remove\":\n+            if len(args) < 3:\n+                print(\"Usage: pairing remove <user_open_id>\")\n+                return 1\n+            if pairing_remove(args[2]):\n+                print(f\"Removed: {args[2]}\")\n+            else:\n+                print(f\"Not in approved list: {args[2]}\")\n+        elif sub == \"list\":\n+            approved = pairing_list()\n+            if not approved:\n+                print(\"(no approved users)\")\n+            for uid, meta in sorted(approved.items()):\n+                print(f\"  {uid}  approved_at={meta.get('approved_at', '?')}\")\n+        else:\n+            print(f\"Unknown pairing subcommand: {sub}\")\n+            return 1\n+    else:\n+        print(f\"Unknown command: {cmd}\\n\")\n+        print(usage)\n+        return 1\n+    return 0\n+\n+\n+if __name__ == \"__main__\":\n+    import sys\n+    sys.exit(_main())"]},{"file":"scripts/release.py","hunks":["@@ -75,6 +75,7 @@\n     \"Asunfly@users.noreply.github.com\": \"Asunfly\",\n     # contributors (manual mapping from git names)\n     \"[redacted:email]\": \"asheriif\",\n+    \"[redacted:email]\": \"liujinkun2025\",\n     \"[redacted:email]\": \"dmahan93\",\n     \"[redacted:email]\": \"samherring99\",\n     \"[redacted:email]\": \"Aum08Desai\","]},{"file":"tests/gateway/test_feishu.py","hunks":["@@ -601,6 +601,10 @@ def register_p2_im_message_recalled_v1(self, _handler):\n                 calls.append(\"message_recalled\")\n                 return self\n \n+            def register_p2_customized_event(self, event_key, _handler):\n+                calls.append(f\"customized:{event_key}\")\n+                return self\n+\n             def build(self):\n                 calls.append(\"build\")\n                 return \"handler\"","@@ -628,6 +632,7 @@ def builder(_encrypt_key, _verification_token):\n                 \"bot_deleted\",\n                 \"p2p_chat_entered\",\n                 \"message_recalled\",\n+                \"customized:drive.notice.comment_add_v1\",\n                 \"build\",\n             ],\n         )"]},{"file":"tests/gateway/test_feishu_comment.py","hunks":["@@ -0,0 +1,261 @@\n+\"\"\"Tests for feishu_comment — event filtering, access control integration, wiki reverse lookup.\"\"\"\n+\n+import asyncio\n+import json\n+import unittest\n+from types import SimpleNamespace\n+from unittest.mock import AsyncMock, Mock, patch\n+\n+from gateway.platforms.feishu_comment import (\n+    parse_drive_comment_event,\n+    _ALLOWED_NOTICE_TYPES,\n+    _sanitize_comment_text,\n+)\n+\n+\n+def _make_event(\n+    comment_id=\"c1\",\n+    reply_id=\"r1\",\n+    notice_type=\"add_reply\",\n+    file_token=\"[redacted:secret-assignment]\",\n+    file_type=\"docx\",\n+    from_open_id=\"ou_user\",\n+    to_open_id=\"ou_bot\",\n+    is_mentioned=True,\n+):\n+    \"\"\"Build a minimal drive comment event SimpleNamespace.\"\"\"\n+    return SimpleNamespace(event={\n+        \"event_id\": \"evt_1\",\n+        \"comment_id\": comment_id,\n+        \"reply_id\": reply_id,\n+        \"is_mentioned\": is_mentioned,\n+        \"timestamp\": \"1713200000\",\n+        \"notice_meta\": {\n+            \"file_token\": [redacted:secret-assignment]\n+            \"file_type\": file_type,\n+            \"notice_type\": notice_type,\n+            \"from_user_id\": {\"open_id\": from_open_id},\n+            \"to_user_id\": {\"open_id\": to_open_id},\n+        },\n+    })\n+\n+\n+class TestParseEvent(unittest.TestCase):\n+    def test_parse_valid_event(self):\n+        evt = _make_event()\n+        parsed = parse_drive_comment_event(evt)\n+        self.assertIsNotNone(parsed)\n+        self.assertEqual(parsed[\"comment_id\"], \"c1\")\n+        self.assertEqual(parsed[\"file_type\"], \"docx\")\n+        self.assertEqual(parsed[\"from_open_id\"], \"ou_user\")\n+        self.assertEqual(parsed[\"to_open_id\"], \"ou_bot\")\n+\n+    def test_parse_missing_event_attr(self):\n+        self.assertIsNone(parse_drive_comment_event(object()))\n+\n+    def test_parse_none_event(self):\n+        self.assertIsNone(parse_drive_comment_event(SimpleNamespace()))\n+\n+\n+class TestEventFiltering(unittest.TestCase):\n+    \"\"\"Test the filtering logic in handle_drive_comment_event.\"\"\"\n+\n+    def _run(self, coro):\n+        return asyncio.get_event_loop().run_until_complete(coro)\n+\n+    @patch(\"gateway.platforms.feishu_comment_rules.load_config\")\n+    @patch(\"gateway.platforms.feishu_comment_rules.resolve_rule\")\n+    @patch(\"gateway.platforms.feishu_comment_rules.is_user_allowed\")\n+    def test_self_reply_filtered(self, mock_allowed, mock_resolve, mock_load):\n+        \"\"\"Events where from_open_id == self_open_id should be dropped.\"\"\"\n+        from gateway.platforms.feishu_comment import handle_drive_comment_event\n+\n+        evt = _make_event(from_open_id=\"ou_bot\", to_open_id=\"ou_bot\")\n+        self._run(handle_drive_comment_event(Mock(), evt, self_open_id=\"ou_bot\"))\n+        mock_load.assert_not_called()\n+\n+    @patch(\"gateway.platforms.feishu_comment_rules.load_config\")\n+    @patch(\"gateway.platforms.feishu_comment_rules.resolve_rule\")\n+    @patch(\"gateway.platforms.feishu_comment_rules.is_user_allowed\")\n+    def test_wrong_receiver_filtered(self, mock_allowed, mock_resolve, mock_load):\n+        \"\"\"Events where to_open_id != self_open_id should be dropped.\"\"\"\n+        from gateway.platforms.feishu_comment import handle_drive_comment_event\n+\n+        evt = _make_event(to_open_id=\"ou_other_bot\")\n+        self._run(handle_drive_comment_event(Mock(), evt, self_open_id=\"ou_bot\"))\n+        mock_load.assert_not_called()\n+\n+    @patch(\"gateway.platforms.feishu_comment_rules.load_config\")\n+    @patch(\"gateway.platforms.feishu_comment_rules.resolve_rule\")\n+    @patch(\"gateway.platforms.feishu_comment_rules.is_user_allowed\")\n+    def test_empty_to_open_id_filtered(self, mock_allowed, mock_resolve, mock_load):\n+        \"\"\"Events with empty to_open_id should be dropped.\"\"\"\n+        from gateway.platforms.feishu_comment import handle_drive_comment_event\n+\n+        evt = _make_event(to_open_id=\"\")\n+        self._run(handle_drive_comment_event(Mock(), evt, self_open_id=\"ou_bot\"))\n+        mock_load.assert_not_called()\n+\n+    @patch(\"gateway.platforms.feishu_comment_rules.load_config\")\n+    @patch(\"gateway.platforms.feishu_comment_rules.resolve_rule\")\n+    @patch(\"gateway.platforms.feishu_comment_rules.is_user_allowed\")\n+    def test_invalid_notice_type_filtered(self, mock_allowed, mock_resolve, mock_load):\n+        \"\"\"Events with unsupported notice_type should be dropped.\"\"\"\n+        from gateway.platforms.feishu_comment import handle_drive_comment_event\n+\n+        evt = _make_event(notice_type=\"resolve_comment\")\n+        self._run(handle_drive_comment_event(Mock(), evt, self_open_id=\"ou_bot\"))\n+        mock_load.assert_not_called()\n+\n+    def test_allowed_notice_types(self):\n+        self.assertIn(\"add_comment\", _ALLOWED_NOTICE_TYPES)\n+        self.assertIn(\"add_reply\", _ALLOWED_NOTICE_TYPES)\n+        self.assertNotIn(\"resolve_comment\", _ALLOWED_NOTICE_TYPES)\n+\n+\n+class TestAccessControlIntegration(unittest.TestCase):\n+    def _run(self, coro):\n+        return asyncio.get_event_loop().run_until_complete(coro)\n+\n+    @patch(\"gateway.platforms.feishu_comment_rules.has_wiki_keys\", return_value=False)\n+    @patch(\"gateway.platforms.feishu_comment_rules.is_user_allowed\", return_value=False)\n+    @patch(\"gateway.platforms.feishu_comment_rules.resolve_rule\")\n+    @patch(\"gateway.platforms.feishu_comment_rules.load_config\")\n+    def test_denied_user_no_side_effects(self, mock_load, mock_resolve, mock_allowed, mock_wiki_keys):\n+        \"\"\"Denied user should not trigger typing reaction or agent.\"\"\"\n+        from gateway.platforms.feishu_comment import handle_drive_comment_event\n+        from gateway.platforms.feishu_comment_rules import ResolvedCommentRule\n+\n+        mock_resolve.return_value = ResolvedCommentRule(True, \"allowlist\", frozenset(), \"top\")\n+        mock_load.return_value = Mock()\n+\n+        client = Mock()\n+        evt = _make_event()\n+        self._run(handle_drive_comment_event(client, evt, self_open_id=\"ou_bot\"))\n+\n+        # No API calls should be made for denied users\n+        client.request.assert_not_called()\n+\n+    @patch(\"gateway.platforms.feishu_comment_rules.has_wiki_keys\", return_value=False)\n+    @patch(\"gateway.platforms.feishu_comment_rules.is_user_allowed\", return_value=False)\n+    @patch(\"gateway.platforms.feishu_comment_rules.resolve_rule\")\n+    @patch(\"gateway.platforms.feishu_comment_rules.load_config\")\n+    def test_disabled_comment_skipped(self, mock_load, mock_resolve, mock_allowed, mock_wiki_keys):\n+        \"\"\"Disabled comments should return immediately.\"\"\"\n+        from gateway.platforms.feishu_comment import handle_drive_comment_event\n+        from gateway.platforms.feishu_comment_rules import ResolvedCommentRule\n+\n+        mock_resolve.return_value = ResolvedCommentRule(False, \"allowlist\", frozenset(), \"top\")\n+        mock_load.return_value = Mock()\n+\n+        evt = _make_event()\n+        self._run(handle_drive_comment_event(Mock(), evt, self_open_id=\"ou_bot\"))\n+        mock_allowed.assert_not_called()\n+\n+\n+class TestSanitizeCommentText(unittest.TestCase):\n+    def test_angle_brackets_escaped(self):\n+        self.assertEqual(_sanitize_comment_text(\"List<String>\"), \"List&lt;String&gt;\")\n+\n+    def test_ampersand_escaped_first(self):\n+        self.assertEqual(_sanitize_comment_text(\"a & b\"), \"a &amp; b\")\n+\n+    def test_ampersand_not_double_escaped(self):\n+        result = _sanitize_comment_text(\"a < b & c > d\")\n+        self.assertEqual(result, \"a &lt; b &amp; c &gt; d\")\n+        self.assertNotIn(\"&amp;lt;\", result)\n+        self.assertNotIn(\"&amp;gt;\", result)\n+\n+    def test_plain_text_unchanged(self):\n+        self.assertEqual(_sanitize_comment_text(\"hello world\"), \"hello world\")\n+\n+    def test_empty_string(self):\n+        self.assertEqual(_sanitize_comment_text(\"\"), \"\")\n+\n+    def test_code_snippet(self):\n+        text = 'if (a < b && c > 0) { return \"ok\"; }'\n+        result = _sanitize_comment_text(text)\n+        self.assertNotIn(\"<\", result)\n+        self.assertNotIn(\">\", result)\n+        self.assertIn(\"&lt;\", result)\n+        self.assertIn(\"&gt;\", result)\n+\n+\n+class TestWikiReverseLookup(unittest.TestCase):\n+    def _run(self, coro):\n+        return asyncio.get_event_loop().run_until_complete(coro)\n+\n+    @patch(\"gateway.platforms.feishu_comment._exec_request\")\n+    def test_reverse_lookup_success(self, mock_exec):\n+        from gateway.platforms.feishu_comment import _reverse_lookup_wiki_token\n+\n+        mock_exec.return_value = (0, \"Success\", {\n+            \"node\": {\"node_token\": \"[redacted:secret-assignment]\", \"obj_token\": \"[redacted:secret-assignment]\"},\n+        })\n+        result = self._run(_reverse_lookup_wiki_token(Mock(), \"docx\", \"docx_abc\"))\n+        self.assertEqual(result, \"WIKI_TOKEN_123\")\n+        # Verify correct API params\n+        call_args = mock_exec.call_args\n+        queries = call_args[1].get(\"queries\") or call_args[0][3]\n+        query_dict = dict(queries)\n+        self.assertEqual(query_dict[\"token\"], \"docx_abc\")\n+        self.assertEqual(query_dict[\"obj_type\"], \"docx\")\n+\n+    @patch(\"gateway.platforms.feishu_comment._exec_request\")\n+    def test_reverse_lookup_not_wiki(self, mock_exec):\n+        from gateway.platforms.feishu_comment import _reverse_lookup_wiki_token\n+\n+        mock_exec.return_value = (131001, \"not found\", {})\n+        result = self._run(_reverse_lookup_wiki_token(Mock(), \"docx\", \"docx_abc\"))\n+        self.assertIsNone(result)\n+\n+    @patch(\"gateway.platforms.feishu_comment._exec_request\")\n+    def test_reverse_lookup_service_error(self, mock_exec):\n+        from gateway.platforms.feishu_comment import _reverse_lookup_wiki_token\n+\n+        mock_exec.return_value = (500, \"internal error\", {})\n+        result = self._run(_reverse_lookup_wiki_token(Mock(), \"docx\", \"docx_abc\"))\n+        self.assertIsNone(result)\n+\n+    @patch(\"gateway.platforms.feishu_comment._reverse_lookup_wiki_token\", new_callable=AsyncMock)\n+    @patch(\"gateway.platforms.feishu_comment_rules.has_wiki_keys\", return_value=True)\n+    @patch(\"gateway.platforms.feishu_comment_rules.is_user_allowed\", return_value=True)\n+    @patch(\"gateway.platforms.feishu_comment_rules.resolve_rule\")\n+    @patch(\"gateway.platforms.feishu_comment_rules.load_config\")\n+    @patch(\"gateway.platforms.feishu_comment.add_comment_reaction\", new_callable=AsyncMock)\n+    @patch(\"gateway.platforms.feishu_comment.batch_query_comment\", new_callable=AsyncMock)\n+    @patch(\"gateway.platforms.feishu_comment.query_document_meta\", new_callable=AsyncMock)\n+    def test_wiki_lookup_triggered_when_no_exact_match(\n+        self, mock_meta, mock_batch, mock_reaction,\n+        mock_load, mock_resolve, mock_allowed, mock_wiki_keys, mock_lookup,\n+    ):\n+        \"\"\"Wiki reverse lookup should fire when rule falls to wildcard/top and wiki keys exist.\"\"\"\n+        from gateway.platforms.feishu_comment import handle_drive_comment_event\n+        from gateway.platforms.feishu_comment_rules import ResolvedCommentRule\n+\n+        # First resolve returns wildcard (no exact match), second returns exact wiki match\n+        mock_resolve.side_effect = [\n+            ResolvedCommentRule(True, \"allowlist\", frozenset(), \"wildcard\"),\n+            ResolvedCommentRule(True, \"allowlist\", frozenset(), \"exact:wiki:WIKI123\"),\n+        ]\n+        mock_load.return_value = Mock()\n+        mock_lookup.return_value = \"WIKI123\"\n+        mock_meta.return_value = {\"title\": \"Test\", \"url\": \"\"}\n+        mock_batch.return_value = {\"is_whole\": False, \"quote\": \"\"}\n+\n+        evt = _make_event()\n+        # Will proceed past access control but fail later — that's OK, we just test the lookup\n+        try:\n+            self._run(handle_drive_comment_event(Mock(), evt, self_open_id=\"ou_bot\"))\n+        except Exception:\n+            pass\n+\n+        mock_lookup.assert_called_once_with(unittest.mock.ANY, \"docx\", \"docx_token\")\n+        self.assertEqual(mock_resolve.call_count, 2)\n+        # Second call should include wiki_token\n+        second_call_kwargs = mock_resolve.call_args_list[1]\n+        self.assertEqual(second_call_kwargs[1].get(\"wiki_token\") or second_call_kwargs[0][3], \"WIKI123\")\n+\n+\n+if __name__ == \"__main__\":\n+    unittest.main()"]},{"file":"tests/gateway/test_feishu_comment_rules.py","hunks":["@@ -0,0 +1,320 @@\n+\"\"\"Tests for feishu_comment_rules — 3-tier access control rule engine.\"\"\"\n+\n+import json\n+import os\n+import tempfile\n+import time\n+import unittest\n+from pathlib import Path\n+from unittest.mock import patch\n+\n+from gateway.platforms.feishu_comment_rules import (\n+    CommentsConfig,\n+    CommentDocumentRule,\n+    ResolvedCommentRule,\n+    _MtimeCache,\n+    _parse_document_rule,\n+    has_wiki_keys,\n+    is_user_allowed,\n+    load_config,\n+    pairing_add,\n+    pairing_list,\n+    pairing_remove,\n+    resolve_rule,\n+)\n+\n+\n+class TestCommentDocumentRuleParsing(unittest.TestCase):\n+    def test_parse_full_rule(self):\n+        rule = _parse_document_rule({\n+            \"enabled\": False,\n+            \"policy\": \"allowlist\",\n+            \"allow_from\": [\"ou_a\", \"ou_b\"],\n+        })\n+        self.assertFalse(rule.enabled)\n+        self.assertEqual(rule.policy, \"allowlist\")\n+        self.assertEqual(rule.allow_from, frozenset([\"ou_a\", \"ou_b\"]))\n+\n+    def test_parse_partial_rule(self):\n+        rule = _parse_document_rule({\"policy\": \"allowlist\"})\n+        self.assertIsNone(rule.enabled)\n+        self.assertEqual(rule.policy, \"allowlist\")\n+        self.assertIsNone(rule.allow_from)\n+\n+    def test_parse_empty_rule(self):\n+        rule = _parse_document_rule({})\n+        self.assertIsNone(rule.enabled)\n+        self.assertIsNone(rule.policy)\n+        self.assertIsNone(rule.allow_from)\n+\n+    def test_invalid_policy_ignored(self):\n+        rule = _parse_document_rule({\"policy\": \"invalid_value\"})\n+        self.assertIsNone(rule.policy)\n+\n+\n+class TestResolveRule(unittest.TestCase):\n+    def test_exact_match(self):\n+        cfg = CommentsConfig(\n+            policy=\"pairing\",\n+            allow_from=frozenset([\"ou_top\"]),\n+            documents={\n+                \"docx:abc\": CommentDocumentRule(policy=\"allowlist\"),\n+            },\n+        )\n+        rule = resolve_rule(cfg, \"docx\", \"abc\")\n+        self.assertEqual(rule.policy, \"allowlist\")\n+        self.assertTrue(rule.match_source.startswith(\"exact:\"))\n+\n+    def test_wildcard_match(self):\n+        cfg = CommentsConfig(\n+            policy=\"pairing\",\n+            documents={\n+                \"*\": CommentDocumentRule(policy=\"allowlist\"),\n+            },\n+        )\n+        rule = resolve_rule(cfg, \"docx\", \"unknown\")\n+        self.assertEqual(rule.policy, \"allowlist\")\n+        self.assertEqual(rule.match_source, \"wildcard\")\n+\n+    def test_top_level_fallback(self):\n+        cfg = CommentsConfig(policy=\"pairing\", allow_from=frozenset([\"ou_top\"]))\n+        rule = resolve_rule(cfg, \"docx\", \"whatever\")\n+        self.assertEqual(rule.policy, \"pairing\")\n+        self.assertEqual(rule.allow_from, frozenset([\"ou_top\"]))\n+        self.assertEqual(rule.match_source, \"top\")\n+\n+    def test_exact_overrides_wildcard(self):\n+        cfg = CommentsConfig(\n+            policy=\"pairing\",\n+            documents={\n+                \"*\": CommentDocumentRule(policy=\"pairing\"),\n+                \"docx:abc\": CommentDocumentRule(policy=\"allowlist\"),\n+            },\n+        )\n+        rule = resolve_rule(cfg, \"docx\", \"abc\")\n+        self.assertEqual(rule.policy, \"allowlist\")\n+        self.assertTrue(rule.match_source.startswith(\"exact:\"))\n+\n+    def test_field_by_field_fallback(self):\n+        \"\"\"Exact sets policy, wildcard sets allow_from, enabled from top.\"\"\"\n+        cfg = CommentsConfig(\n+            enabled=True,\n+            policy=\"pairing\",\n+            allow_from=frozenset([\"ou_top\"]),\n+            documents={\n+                \"*\": CommentDocumentRule(allow_from=frozenset([\"ou_wildcard\"])),\n+                \"docx:abc\": CommentDocumentRule(policy=\"allowlist\"),\n+            },\n+        )\n+        rule = resolve_rule(cfg, \"docx\", \"abc\")\n+        self.assertEqual(rule.policy, \"allowlist\")\n+        self.assertEqual(rule.allow_from, frozenset([\"ou_wildcard\"]))\n+        self.assertTrue(rule.enabled)\n+\n+    def test_explicit_empty_allow_from_does_not_fall_through(self):\n+        \"\"\"allow_from=[] on exact should NOT inherit from wildcard or top.\"\"\"\n+        cfg = CommentsConfig(\n+            allow_from=frozenset([\"ou_top\"]),\n+            documents={\n+                \"*\": CommentDocumentRule(allow_from=frozenset([\"ou_wildcard\"])),\n+                \"docx:abc\": CommentDocumentRule(\n+                    policy=\"allowlist\",\n+                    allow_from=frozenset(),\n+                ),\n+            },\n+        )\n+        rule = resolve_rule(cfg, \"docx\", \"abc\")\n+        self.assertEqual(rule.allow_from, frozenset())\n+\n+    def test_wiki_token_match(self):\n+        cfg = CommentsConfig(\n+            policy=\"pairing\",\n+            documents={\n+                \"wiki:WIKI123\": CommentDocumentRule(policy=\"allowlist\"),\n+            },\n+        )\n+        rule = resolve_rule(cfg, \"docx\", \"obj_token\", wiki_token=\"[redacted:secret-assignment]\")\n+        self.assertEqual(rule.policy, \"allowlist\")\n+        self.assertTrue(rule.match_source.startswith(\"exact:wiki:\"))\n+\n+    def test_exact_takes_priority_over_wiki(self):\n+        cfg = CommentsConfig(\n+            documents={\n+                \"docx:abc\": CommentDocumentRule(policy=\"allowlist\"),\n+                \"wiki:WIKI123\": CommentDocumentRule(policy=\"pairing\"),\n+            },\n+        )\n+        rule = resolve_rule(cfg, \"docx\", \"abc\", wiki_token=\"[redacted:secret-assignment]\")\n+        self.assertEqual(rule.policy, \"allowlist\")\n+        self.assertTrue(rule.match_source.startswith(\"exact:docx:\"))\n+\n+    def test_default_config(self):\n+        cfg = CommentsConfig()\n+        rule = resolve_rule(cfg, \"docx\", \"anything\")\n+        self.assertTrue(rule.enabled)\n+        self.assertEqual(rule.policy, \"pairing\")\n+        self.assertEqual(rule.allow_from, frozenset())\n+\n+\n+class TestHasWikiKeys(unittest.TestCase):\n+    def test_no_wiki_keys(self):\n+        cfg = CommentsConfig(documents={\n+            \"docx:abc\": CommentDocumentRule(policy=\"allowlist\"),\n+            \"*\": CommentDocumentRule(policy=\"pairing\"),\n+        })\n+        self.assertFalse(has_wiki_keys(cfg))\n+\n+    def test_has_wiki_keys(self):\n+        cfg = CommentsConfig(documents={\n+            \"wiki:WIKI123\": CommentDocumentRule(policy=\"allowlist\"),\n+        })\n+        self.assertTrue(has_wiki_keys(cfg))\n+\n+    def test_empty_documents(self):\n+        cfg = CommentsConfig()\n+        self.assertFalse(has_wiki_keys(cfg))\n+\n+\n+class TestIsUserAllowed(unittest.TestCase):\n+    def test_allowlist_allows_listed(self):\n+        rule = ResolvedCommentRule(True, \"allowlist\", frozenset([\"ou_a\"]), \"top\")\n+        self.assertTrue(is_user_allowed(rule, \"ou_a\"))\n+\n+    def test_allowlist_denies_unlisted(self):\n+        rule = ResolvedCommentRule(True, \"allowlist\", frozenset([\"ou_a\"]), \"top\")\n+        self.assertFalse(is_user_allowed(rule, \"ou_b\"))\n+\n+    def test_allowlist_empty_denies_all(self):\n+        rule = ResolvedCommentRule(True, \"allowlist\", frozenset(), \"top\")\n+        self.assertFalse(is_user_allowed(rule, \"ou_anyone\"))\n+\n+    def test_pairing_allows_in_allow_from(self):\n+        rule = ResolvedCommentRule(True, \"pairing\", frozenset([\"ou_a\"]), \"top\")\n+        self.assertTrue(is_user_allowed(rule, \"ou_a\"))\n+\n+    def test_pairing_checks_store(self):\n+        rule = ResolvedCommentRule(True, \"pairing\", frozenset(), \"top\")\n+        with patch(\n+            \"gateway.platforms.feishu_comment_rules._load_pairing_approved\",\n+            return_value={\"ou_approved\"},\n+        ):\n+            self.assertTrue(is_user_allowed(rule, \"ou_approved\"))\n+            self.assertFalse(is_user_allowed(rule, \"ou_unknown\"))\n+\n+\n+class TestMtimeCache(unittest.TestCase):\n+    def test_returns_empty_dict_for_missing_file(self):\n+        cache = _MtimeCache(Path(\"/nonexistent/path.json\"))\n+        self.assertEqual(cache.load(), {})\n+\n+    def test_reads_file_and_caches(self):\n+        with tempfile.NamedTemporaryFile(mode=\"w\", suffix=\".json\", delete=False) as f:\n+            json.dump({\"key\": \"value\"}, f)\n+            f.flush()\n+            path = Path(f.name)\n+        try:\n+            cache = _MtimeCache(path)\n+            data = cache.load()\n+            self.assertEqual(data, {\"key\": \"value\"})\n+            # Second load should use cache (same mtime)\n+            data2 = cache.load()\n+            self.assertEqual(data2, {\"key\": \"value\"})\n+        finally:\n+            path.unlink()\n+\n+    def test_reloads_on_mtime_change(self):\n+        with tempfile.NamedTemporaryFile(mode=\"w\", suffix=\".json\", delete=False) as f:\n+            json.dump({\"v\": 1}, f)\n+            f.flush()\n+            path = Path(f.name)\n+        try:\n+            cache = _MtimeCache(path)\n+            self.assertEqual(cache.load(), {\"v\": 1})\n+            # Modify file\n+            time.sleep(0.05)\n+            with open(path, \"w\") as f2:\n+                json.dump({\"v\": 2}, f2)\n+            # Force mtime change detection\n+            os.utime(path, (time.time() + 1, time.time() + 1))\n+            self.assertEqual(cache.load(), {\"v\": 2})\n+        finally:\n+            path.unlink()\n+\n+\n+class TestLoadConfig(unittest.TestCase):\n+    def test_load_with_documents(self):\n+        raw = {\n+            \"enabled\": True,\n+            \"policy\": \"allowlist\",\n+            \"allow_from\": [\"ou_a\"],\n+            \"documents\": {\n+                \"*\": {\"policy\": \"pairing\"},\n+                \"docx:abc\": {\"policy\": \"allowlist\", \"allow_from\": [\"ou_b\"]},\n+            },\n+        }\n+        with tempfile.NamedTemporaryFile(mode=\"w\", suffix=\".json\", delete=False) as f:\n+            json.dump(raw, f)\n+            path = Path(f.name)\n+        try:\n+            with patch(\"gateway.platforms.feishu_comment_rules.RULES_FILE\", path):\n+                with patch(\"gateway.platforms.feishu_comment_rules._rules_cache\", _MtimeCache(path)):\n+                    cfg = load_config()\n+            self.assertTrue(cfg.enabled)\n+            self.assertEqual(cfg.policy, \"allowlist\")\n+            self.assertEqual(cfg.allow_from, frozenset([\"ou_a\"]))\n+            self.assertIn(\"*\", cfg.documents)\n+            self.assertIn(\"docx:abc\", cfg.documents)\n+            self.assertEqual(cfg.documents[\"docx:abc\"].policy, \"allowlist\")\n+        finally:\n+            path.unlink()\n+\n+    def test_load_missing_file_returns_defaults(self):\n+        with patch(\"gateway.platforms.feishu_comment_rules._rules_cache\", _MtimeCache(Path(\"/nonexistent\"))):\n+            cfg = load_config()\n+        self.assertTrue(cfg.enabled)\n+        self.assertEqual(cfg.policy, \"pairing\")\n+        self.assertEqual(cfg.allow_from, frozenset())\n+        self.assertEqual(cfg.documents, {})\n+\n+\n+class TestPairingStore(unittest.TestCase):\n+    def setUp(self):\n+        self._tmpdir = tempfile.mkdtemp()\n+        self._pairing_file = Path(self._tmpdir) / \"pairing.json\"\n+        with open(self._pairing_file, \"w\") as f:\n+            json.dump({\"approved\": {}}, f)\n+        self._patcher_file = patch(\"gateway.platforms.feishu_comment_rules.PAIRING_FILE\", self._pairing_file)\n+        self._patcher_cache = patch(\n+            \"gateway.platforms.feishu_comment_rules._pairing_cache\",\n+            _MtimeCache(self._pairing_file),\n+        )\n+        self._patcher_file.start()\n+        self._patcher_cache.start()\n+\n+    def tearDown(self):\n+        self._patcher_cache.stop()\n+        self._patcher_file.stop()\n+        if self._pairing_file.exists():\n+            self._pairing_file.unlink()\n+        os.rmdir(self._tmpdir)\n+\n+    def test_add_and_list(self):\n+        self.assertTrue(pairing_add(\"ou_new\"))\n+        approved = pairing_list()\n+        self.assertIn(\"ou_new\", approved)\n+\n+    def test_add_duplicate(self):\n+        pairing_add(\"ou_a\")\n+        self.assertFalse(pairing_add(\"ou_a\"))\n+\n+    def test_remove(self):\n+        pairing_add(\"ou_a\")\n+        self.assertTrue(pairing_remove(\"ou_a\"))\n+        self.assertNotIn(\"ou_a\", pairing_list())\n+\n+    def test_remove_nonexistent(self):\n+        self.assertFalse(pairing_remove(\"ou_nobody\"))\n+\n+\n+if __name__ == \"__main__\":\n+    unittest.main()"]},{"file":"tests/tools/test_feishu_tools.py","hunks":["@@ -0,0 +1,62 @@\n+\"\"\"Tests for feishu_doc_tool and feishu_drive_tool — registration and schema validation.\"\"\"\n+\n+import importlib\n+import unittest\n+\n+from tools.registry import registry\n+\n+# Trigger tool discovery so feishu tools get registered\n+importlib.import_module(\"tools.feishu_doc_tool\")\n+importlib.import_module(\"tools.feishu_drive_tool\")\n+\n+\n+class TestFeishuToolRegistration(unittest.TestCase):\n+    \"\"\"Verify feishu tools are registered and have valid schemas.\"\"\"\n+\n+    EXPECTED_TOOLS = {\n+        \"feishu_doc_read\": \"feishu_doc\",\n+        \"feishu_drive_list_comments\": \"feishu_drive\",\n+        \"feishu_drive_list_comment_replies\": \"feishu_drive\",\n+        \"feishu_drive_reply_comment\": \"feishu_drive\",\n+        \"feishu_drive_add_comment\": \"feishu_drive\",\n+    }\n+\n+    def test_all_tools_registered(self):\n+        for tool_name, toolset in self.EXPECTED_TOOLS.items():\n+            entry = registry.get_entry(tool_name)\n+            self.assertIsNotNone(entry, f\"{tool_name} not registered\")\n+            self.assertEqual(entry.toolset, toolset)\n+\n+    def test_schemas_have_required_fields(self):\n+        for tool_name in self.EXPECTED_TOOLS:\n+            entry = registry.get_entry(tool_name)\n+            schema = entry.schema\n+            self.assertIn(\"name\", schema)\n+            self.assertEqual(schema[\"name\"], tool_name)\n+            self.assertIn(\"description\", schema)\n+            self.assertIn(\"parameters\", schema)\n+            self.assertIn(\"type\", schema[\"parameters\"])\n+            self.assertEqual(schema[\"parameters\"][\"type\"], \"object\")\n+\n+    def test_handlers_are_callable(self):\n+        for tool_name in self.EXPECTED_TOOLS:\n+            entry = registry.get_entry(tool_name)\n+            self.assertTrue(callable(entry.handler))\n+\n+    def test_doc_read_schema_params(self):\n+        entry = registry.get_entry(\"feishu_doc_read\")\n+        props = entry.schema[\"parameters\"].get(\"properties\", {})\n+        self.assertIn(\"doc_token\", props)\n+\n+    def test_drive_tools_require_file_token(self):\n+        for tool_name in self.EXPECTED_TOOLS:\n+            if tool_name == \"feishu_doc_read\":\n+                continue\n+            entry = registry.get_entry(tool_name)\n+            props = entry.schema[\"parameters\"].get(\"properties\", {})\n+            self.assertIn(\"file_token\", props, f\"{tool_name} missing file_token param\")\n+            self.assertIn(\"file_type\", props, f\"{tool_name} missing file_type param\")\n+\n+\n+if __name__ == \"__main__\":\n+    unittest.main()"]},{"file":"tools/feishu_doc_tool.py","hunks":["@@ -0,0 +1,131 @@\n+\"\"\"Feishu Document Tool -- read document content via Feishu/Lark API.\n+\n+Provides ``feishu_doc_read`` for reading document content as plain text.\n+Uses the same lazy-import + BaseRequest pattern as feishu_comment.py.\n+\"\"\"\n+\n+import json\n+import logging\n+import threading\n+\n+from tools.registry import registry, tool_error, tool_result\n+\n+logger = logging.getLogger(__name__)\n+\n+# Thread-local storage for the lark client injected by feishu_comment handler.\n+_local = threading.local()\n+\n+\n+def set_client(client):\n+    \"\"\"Store a lark client for the current thread (called by feishu_comment).\"\"\"\n+    _local.client = client\n+\n+\n+def get_client():\n+    \"\"\"Return the lark client for the current thread, or None.\"\"\"\n+    return getattr(_local, \"client\", None)\n+\n+\n+# ---------------------------------------------------------------------------\n+# feishu_doc_read\n+# ---------------------------------------------------------------------------\n+\n+_RAW_CONTENT_URI = \"/open-apis/docx/v1/documents/:document_id/raw_content\"\n+\n+FEISHU_DOC_READ_SCHEMA = {\n+    \"name\": \"feishu_doc_read\",\n+    \"description\": (\n+        \"Read the full content of a Feishu/Lark document as plain text. \"\n+        \"Useful when you need more context beyond the quoted text in a comment.\"\n+    ),\n+    \"parameters\": {\n+        \"type\": \"object\",\n+        \"properties\": {\n+            \"doc_token\": {\n+                \"type\": \"string\",\n+                \"description\": \"The document token (from the document URL or comment context).\",\n+            },\n+        },\n+        \"required\": [\"doc_token\"],\n+    },\n+}\n+\n+\n+def _check_feishu():\n+    try:\n+        import lark_oapi  # noqa: F401\n+        return True\n+    except ImportError:\n+        return False\n+\n+\n+def _handle_feishu_doc_read(args: dict, **kwargs) -> str:\n+    doc_token = args.get(\"doc_token\", \"\").strip()\n+    if not doc_token:\n+        return tool_error(\"doc_token is required\")\n+\n+    client = get_client()\n+    if client is None:\n+        return tool_error(\"Feishu client not available (not in a Feishu comment context)\")\n+\n+    try:\n+        from lark_oapi import AccessTokenType\n+        from lark_oapi.core.enum import HttpMethod\n+        from lark_oapi.core.model.base_request import BaseRequest\n+    except ImportError:\n+        return tool_error(\"lark_oapi not installed\")\n+\n+    request = (\n+        BaseRequest.builder()\n+        .http_method(HttpMethod.GET)\n+        .uri(_RAW_CONTENT_URI)\n+        .token_types({AccessTokenType.TENANT})\n+        .paths({\"document_id\": doc_token})\n+        .build()\n+    )\n+\n+    # Tool handlers run synchronously in a worker thread (no running event\n+    # loop), so call the blocking lark client directly.\n+    response = client.request(request)\n+\n+    code = getattr(response, \"code\", None)\n+    if code != 0:\n+        msg = getattr(response, \"msg\", \"unknown error\")\n+        return tool_error(f\"Failed to read document: code={code} msg={msg}\")\n+\n+    raw = getattr(response, \"raw\", None)\n+    if raw and hasattr(raw, \"content\"):\n+        try:\n+            body = json.loads(raw.content)\n+            content = body.get(\"data\", {}).get(\"content\", \"\")\n+            return tool_result(success=True, content=content)\n+        except (json.JSONDecodeError, AttributeError):\n+            pass\n+\n+    # Fallback: try response.data\n+    data = getattr(response, \"data\", None)\n+    if data:\n+        if isinstance(data, dict):\n+            content = data.get(\"content\", \"\")\n+        else:\n+            content = getattr(data, \"content\", str(data))\n+        return tool_result(success=True, content=content)\n+\n+    return tool_error(\"No content returned from document API\")\n+\n+\n+# ---------------------------------------------------------------------------\n+# Registration\n+# ---------------------------------------------------------------------------\n+\n+registry.register(\n+    name=\"feishu_doc_read\",\n+    toolset=\"feishu_doc\",\n+    schema=FEISHU_DOC_READ_SCHEMA,\n+    handler=_handle_feishu_doc_read,\n+    check_fn=_check_feishu,\n+    requires_env=[],\n+    is_async=False,\n+    description=\"Read Feishu document content\",\n+    emoji=\"\\U0001f4c4\",\n+)"]},{"file":"tools/feishu_drive_tool.py","hunks":["@@ -0,0 +1,429 @@\n+\"\"\"Feishu Drive Tools -- document comment operations via Feishu/Lark API.\n+\n+Provides tools for listing, replying to, and adding document comments.\n+Uses the same lazy-import + BaseRequest pattern as feishu_comment.py.\n+The lark client is injected per-thread by the comment event handler.\n+\"\"\"\n+\n+import json\n+import logging\n+import threading\n+\n+from tools.registry import registry, tool_error, tool_result\n+\n+logger = logging.getLogger(__name__)\n+\n+# Thread-local storage for the lark client injected by feishu_comment handler.\n+_local = threading.local()\n+\n+\n+def set_client(client):\n+    \"\"\"Store a lark client for the current thread (called by feishu_comment).\"\"\"\n+    _local.client = client\n+\n+\n+def get_client():\n+    \"\"\"Return the lark client for the current thread, or None.\"\"\"\n+    return getattr(_local, \"client\", None)\n+\n+\n+def _check_feishu():\n+    try:\n+        import lark_oapi  # noqa: F401\n+        return True\n+    except ImportError:\n+        return False\n+\n+\n+def _do_request(client, method, uri, paths=None, queries=None, body=None):\n+    \"\"\"Build and execute a BaseRequest, return (code, msg, data_dict).\"\"\"\n+    from lark_oapi import AccessTokenType\n+    from lark_oapi.core.enum import HttpMethod\n+    from lark_oapi.core.model.base_request import BaseRequest\n+\n+    http_method = HttpMethod.GET if method == \"GET\" else HttpMethod.POST\n+\n+    builder = (\n+        BaseRequest.builder()\n+        .http_method(http_method)\n+        .uri(uri)\n+        .token_types({AccessTokenType.TENANT})\n+    )\n+    if paths:\n+        builder = builder.paths(paths)\n+    if queries:\n+        builder = builder.queries(queries)\n+    if body is not None:\n+        builder = builder.body(body)\n+\n+    request = builder.build()\n+\n+    # Tool handlers run synchronously in a worker thread (no running event\n+    # loop), so call the blocking lark client directly.\n+    response = client.request(request)\n+\n+    code = getattr(response, \"code\", None)\n+    msg = getattr(response, \"msg\", \"\")\n+\n+    # Parse response data\n+    data = {}\n+    raw = getattr(response, \"raw\", None)\n+    if raw and hasattr(raw, \"content\"):\n+        try:\n+            body_json = json.loads(raw.content)\n+            data = body_json.get(\"data\", {})\n+        except (json.JSONDecodeError, AttributeError):\n+            pass\n+    if not data:\n+        resp_data = getattr(response, \"data\", None)\n+        if isinstance(resp_data, dict):\n+            data = resp_data\n+        elif resp_data and hasattr(resp_data, \"__dict__\"):\n+            data = vars(resp_data)\n+\n+    return code, msg, data\n+\n+\n+# ---------------------------------------------------------------------------\n+# feishu_drive_list_comments\n+# ---------------------------------------------------------------------------\n+\n+_LIST_COMMENTS_URI = \"/open-apis/drive/v1/files/:file_token/comments\"\n+\n+FEISHU_DRIVE_LIST_COMMENTS_SCHEMA = {\n+    \"name\": \"feishu_drive_list_comments\",\n+    \"description\": (\n+        \"List comments on a Feishu document. \"\n+        \"Use is_whole=true to list whole-document comments only.\"\n+    ),\n+    \"parameters\": {\n+        \"type\": \"object\",\n+        \"properties\": {\n+            \"file_token\": {\n+                \"type\": \"string\",\n+                \"description\": \"The document file token.\",\n+            },\n+            \"file_type\": {\n+                \"type\": \"string\",\n+                \"description\": \"File type (default: docx).\",\n+                \"default\": \"docx\",\n+            },\n+            \"is_whole\": {\n+                \"type\": \"boolean\",\n+                \"description\": \"If true, only return whole-document comments.\",\n+                \"default\": False,\n+            },\n+            \"page_size\": {\n+                \"type\": \"integer\",\n+                \"description\": \"Number of comments per page (max 100).\",\n+                \"default\": 100,\n+            },\n+            \"page_token\": {\n+                \"type\": \"string\",\n+                \"description\": \"Pagination token for next page.\",\n+            },\n+        },\n+        \"required\": [\"file_token\"],\n+    },\n+}\n+\n+\n+def _handle_list_comments(args: dict, **kwargs) -> str:\n+    client = get_client()\n+    if client is None:\n+        return tool_error(\"Feishu client not available\")\n+\n+    file_token = args.get(\"file_token\", \"\").strip()\n+    if not file_token:\n+        return tool_error(\"file_token is required\")\n+\n+    file_type = args.get(\"file_type\", \"docx\") or \"docx\"\n+    is_whole = args.get(\"is_whole\", False)\n+    page_size = args.get(\"page_size\", 100)\n+    page_token = args.get(\"page_token\", \"\")\n+\n+    queries = [\n+        (\"file_type\", file_type),\n+        (\"user_id_type\", \"open_id\"),\n+        (\"page_size\", str(page_size)),\n+    ]\n+    if is_whole:\n+        queries.append((\"is_whole\", \"true\"))\n+    if page_token:\n+        queries.append((\"page_token\", page_token))\n+\n+    code, msg, data = _do_request(\n+        client, \"GET\", _LIST_COMMENTS_URI,\n+        paths={\"file_token\": [redacted:secret-assignment]\n+        queries=queries,\n+    )\n+    if code != 0:\n+        return tool_error(f\"List comments failed: code={code} msg={msg}\")\n+\n+    return tool_result(data)\n+\n+\n+# ---------------------------------------------------------------------------\n+# feishu_drive_list_comment_replies\n+# ---------------------------------------------------------------------------\n+\n+_LIST_REPLIES_URI = \"/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies\"\n+\n+FEISHU_DRIVE_LIST_REPLIES_SCHEMA = {\n+    \"name\": \"feishu_drive_list_comment_replies\",\n+    \"description\": \"List all replies in a comment thread on a Feishu document.\",\n+    \"parameters\": {\n+        \"type\": \"object\",\n+        \"properties\": {\n+            \"file_token\": {\n+                \"type\": \"string\",\n+                \"description\": \"The document file token.\",\n+            },\n+            \"comment_id\": {\n+                \"type\": \"string\",\n+                \"description\": \"The comment ID to list replies for.\",\n+            },\n+            \"file_type\": {\n+                \"type\": \"string\",\n+                \"description\": \"File type (default: docx).\",\n+                \"default\": \"docx\",\n+            },\n+            \"page_size\": {\n+                \"type\": \"integer\",\n+                \"description\": \"Number of replies per page (max 100).\",\n+                \"default\": 100,\n+            },\n+            \"page_token\": {\n+                \"type\": \"string\",\n+                \"description\": \"Pagination token for next page.\",\n+            },\n+        },\n+        \"required\": [\"file_token\", \"comment_id\"],\n+    },\n+}\n+\n+\n+def _handle_list_replies(args: dict, **kwargs) -> str:\n+    client = get_client()\n+    if client is None:\n+        return tool_error(\"Feishu client not available\")\n+\n+    file_token = args.get(\"file_token\", \"\").strip()\n+    comment_id = args.get(\"comment_id\", \"\").strip()\n+    if not file_token or not comment_id:\n+        return tool_error(\"file_token and comment_id are required\")\n+\n+    file_type = args.get(\"file_type\", \"docx\") or \"docx\"\n+    page_size = args.get(\"page_size\", 100)\n+    page_token = args.get(\"page_token\", \"\")\n+\n+    queries = [\n+        (\"file_type\", file_type),\n+        (\"user_id_type\", \"open_id\"),\n+        (\"page_size\", str(page_size)),\n+    ]\n+    if page_token:\n+        queries.append((\"page_token\", page_token))\n+\n+    code, msg, data = _do_request(\n+        client, \"GET\", _LIST_REPLIES_URI,\n+        paths={\"file_token\": [redacted:secret-assignment] \"comment_id\": comment_id},\n+        queries=queries,\n+    )\n+    if code != 0:\n+        return tool_error(f\"List replies failed: code={code} msg={msg}\")\n+\n+    return tool_result(data)\n+\n+\n+# ---------------------------------------------------------------------------\n+# feishu_drive_reply_comment\n+# ---------------------------------------------------------------------------\n+\n+_REPLY_COMMENT_URI = \"/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies\"\n+\n+FEISHU_DRIVE_REPLY_SCHEMA = {\n+    \"name\": \"feishu_drive_reply_comment\",\n+    \"description\": (\n+        \"Reply to a local comment thread on a Feishu document. \"\n+        \"Use this for local (quoted-text) comments. \"\n+        \"For whole-document comments, use feishu_drive_add_comment instead.\"\n+    ),\n+    \"parameters\": {\n+        \"type\": \"object\",\n+        \"properties\": {\n+            \"file_token\": {\n+                \"type\": \"string\",\n+                \"description\": \"The document file token.\",\n+            },\n+            \"comment_id\": {\n+                \"type\": \"string\",\n+                \"description\": \"The comment ID to reply to.\",\n+            },\n+            \"content\": {\n+                \"type\": \"string\",\n+                \"description\": \"The reply text content (plain text only, no markdown).\",\n+            },\n+            \"file_type\": {\n+                \"type\": \"string\",\n+                \"description\": \"File type (default: docx).\",\n+                \"default\": \"docx\",\n+            },\n+        },\n+        \"required\": [\"file_token\", \"comment_id\", \"content\"],\n+    },\n+}\n+\n+\n+def _handle_reply_comment(args: dict, **kwargs) -> str:\n+    client = get_client()\n+    if client is None:\n+        return tool_error(\"Feishu client not available\")\n+\n+    file_token = args.get(\"file_token\", \"\").strip()\n+    comment_id = args.get(\"comment_id\", \"\").strip()\n+    content = args.get(\"content\", \"\").strip()\n+    if not file_token or not comment_id or not content:\n+        return tool_error(\"file_token, comment_id, and content are required\")\n+\n+    file_type = args.get(\"file_type\", \"docx\") or \"docx\"\n+\n+    body = {\n+        \"content\": {\n+            \"elements\": [\n+                {\n+                    \"type\": \"text_run\",\n+                    \"text_run\": {\"text\": content},\n+                }\n+            ]\n+        }\n+    }\n+\n+    code, msg, data = _do_request(\n+        client, \"POST\", _REPLY_COMMENT_URI,\n+        paths={\"file_token\": [redacted:secret-assignment] \"comment_id\": comment_id},\n+        queries=[(\"file_type\", file_type)],\n+        body=body,\n+    )\n+    if code != 0:\n+        return tool_error(f\"Reply comment failed: code={code} msg={msg}\")\n+\n+    return tool_result(success=True, data=data)\n+\n+\n+# ---------------------------------------------------------------------------\n+# feishu_drive_add_comment\n+# ---------------------------------------------------------------------------\n+\n+_ADD_COMMENT_URI = \"/open-apis/drive/v1/files/:file_token/new_comments\"\n+\n+FEISHU_DRIVE_ADD_COMMENT_SCHEMA = {\n+    \"name\": \"feishu_drive_add_comment\",\n+    \"description\": (\n+        \"Add a new whole-document comment on a Feishu document. \"\n+        \"Use this for whole-document comments or as a fallback when \"\n+        \"reply_comment fails with code 1069302.\"\n+    ),\n+    \"parameters\": {\n+        \"type\": \"object\",\n+        \"properties\": {\n+            \"file_token\": {\n+                \"type\": \"string\",\n+                \"description\": \"The document file token.\",\n+            },\n+            \"content\": {\n+                \"type\": \"string\",\n+                \"description\": \"The comment text content (plain text only, no markdown).\",\n+            },\n+            \"file_type\": {\n+                \"type\": \"string\",\n+                \"description\": \"File type (default: docx).\",\n+                \"default\": \"docx\",\n+            },\n+        },\n+        \"required\": [\"file_token\", \"content\"],\n+    },\n+}\n+\n+\n+def _handle_add_comment(args: dict, **kwargs) -> str:\n+    client = get_client()\n+    if client is None:\n+        return tool_error(\"Feishu client not available\")\n+\n+    file_token = args.get(\"file_token\", \"\").strip()\n+    content = args.get(\"content\", \"\").strip()\n+    if not file_token or not content:\n+        return tool_error(\"file_token and content are required\")\n+\n+    file_type = args.get(\"file_type\", \"docx\") or \"docx\"\n+\n+    body = {\n+        \"file_type\": file_type,\n+        \"reply_elements\": [\n+            {\"type\": \"text\", \"text\": content},\n+        ],\n+    }\n+\n+    code, msg, data = _do_request(\n+        client, \"POST\", _ADD_COMMENT_URI,\n+        paths={\"file_token\": [redacted:secret-assignment]\n+        body=body,\n+    )\n+    if code != 0:\n+        return tool_error(f\"Add comment failed: code={code} msg={msg}\")\n+\n+    return tool_result(success=True, data=data)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Registration\n+# ---------------------------------------------------------------------------\n+\n+registry.register(\n+    name=\"feishu_drive_list_comments\",\n+    toolset=\"feishu_drive\",\n+    schema=FEISHU_DRIVE_LIST_COMMENTS_SCHEMA,\n+    handler=_handle_list_comments,\n+    check_fn=_check_feishu,\n+    requires_env=[],\n+    is_async=False,\n+    description=\"List document comments\",\n+    emoji=\"\\U0001f4ac\",\n+)\n+\n+registry.register(\n+    name=\"feishu_drive_list_comment_replies\",\n+    toolset=\"feishu_drive\",\n+    schema=FEISHU_DRIVE_LIST_REPLIES_SCHEMA,\n+    handler=_handle_list_replies,\n+    check_fn=_check_feishu,\n+    requires_env=[],\n+    is_async=False,\n+    description=\"List comment replies\",\n+    emoji=\"\\U0001f4ac\",\n+)\n+\n+registry.register(\n+    name=\"feishu_drive_reply_comment\",\n+    toolset=\"feishu_drive\",\n+    schema=FEISHU_DRIVE_REPLY_SCHEMA,\n+    handler=_handle_reply_comment,\n+    check_fn=_check_feishu,\n+    requires_env=[],\n+    is_async=False,\n+    description=\"Reply to a document comment\",\n+    emoji=\"\\u2709\\ufe0f\",\n+)\n+\n+registry.register(\n+    name=\"feishu_drive_add_comment\",\n+    toolset=\"feishu_drive\",\n+    schema=FEISHU_DRIVE_ADD_COMMENT_SCHEMA,\n+    handler=_handle_add_comment,\n+    check_fn=_check_feishu,\n+    requires_env=[],\n+    is_async=False,\n+    description=\"Add a whole-document comment\",\n+    emoji=\"\\u2709\\ufe0f\",\n+)"]},{"file":"toolsets.py","hunks":["@@ -201,6 +201,21 @@\n         \"includes\": []\n     },\n \n+    \"feishu_doc\": {\n+        \"description\": \"Read Feishu/Lark document content\",\n+        \"tools\": [\"feishu_doc_read\"],\n+        \"includes\": []\n+    },\n+\n+    \"feishu_drive\": {\n+        \"description\": \"Feishu/Lark document comment operations (list, reply, add)\",\n+        \"tools\": [\n+            \"feishu_drive_list_comments\", \"feishu_drive_list_comment_replies\",\n+            \"feishu_drive_reply_comment\", \"feishu_drive_add_comment\",\n+        ],\n+        \"includes\": []\n+    },\n+\n \n     # Scenario-specific toolsets\n     \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}